A course discussion thread list with interactive upvotes, pinned and answered badges, category tags, and an instructor badge on author rows.
npx shadcn@latest add @paragon/discussion-forum"use client";
import * as React from "react";
import {
ChevronUp,
MessageSquare,
CheckCircle2,
GraduationCap,
Pin,
} from "lucide-react";
import { cn } from "@/lib/utils";
export interface ForumThread {
id: string;
title: string;
author: string;
/** Whether the author is an instructor. */
instructor?: boolean;
/** Category / week tag. */
tag?: string;
upvotes: number;
replies: number;
/** Relative time, e.g. "2h". */
time: string;
answered?: boolean;
pinned?: boolean;
/** Whether the viewer has upvoted. */
voted?: boolean;
}
export interface DiscussionForumProps extends React.ComponentProps<"div"> {
course?: string;
threads?: ForumThread[];
}
const DEFAULT_THREADS: ForumThread[] = [
{
id: "t1",
title: "Week 3 office hours moved to Thursday 4pm",
author: "Prof. Adebayo",
instructor: true,
tag: "Announcement",
upvotes: 42,
replies: 6,
time: "1h",
pinned: true,
},
{
id: "t2",
title: "Why does my gradient descent diverge with lr=0.5?",
author: "jhwang",
tag: "Week 3",
upvotes: 18,
replies: 9,
time: "3h",
answered: true,
voted: true,
},
{
id: "t3",
title: "Clarification on the backprop derivation in lecture 6",
author: "sofia.r",
tag: "Week 3",
upvotes: 11,
replies: 4,
time: "5h",
},
{
id: "t4",
title: "Study group for the midterm — anyone in Berlin?",
author: "m.keller",
tag: "General",
upvotes: 7,
replies: 12,
time: "1d",
},
];
/**
* A course discussion thread list: each row shows an interactive upvote control
* (real button, count in tabular figures), the title with pinned / answered
* badges, and author metadata with an instructor badge. Voting is local state
* with a spring-free color transition; deterministic ordering keeps pinned rows
* on top.
*/
export function DiscussionForum({
course = "Deep Learning · Discussion",
threads = DEFAULT_THREADS,
className,
...props
}: DiscussionForumProps) {
const [votes, setVotes] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(threads.map((t) => [t.id, !!t.voted])),
);
const ordered = [...threads].sort(
(a, b) => Number(!!b.pinned) - Number(!!a.pinned),
);
return (
<div
data-slot="discussion-forum"
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h3 className="text-sm font-medium">{course}</h3>
<span className="text-xs text-muted-foreground tabular-nums">
{threads.length} threads
</span>
</div>
<ul>
{ordered.map((t) => {
const voted = votes[t.id];
const count = t.upvotes + (voted && !t.voted ? 1 : 0) - (!voted && t.voted ? 1 : 0);
return (
<li
key={t.id}
className="flex gap-3 border-b border-border px-4 py-3 last:border-b-0"
>
<button
type="button"
aria-pressed={voted}
aria-label={voted ? "Remove upvote" : "Upvote"}
onClick={() =>
setVotes((v) => ({ ...v, [t.id]: !v[t.id] }))
}
className={cn(
"flex h-fit shrink-0 flex-col items-center gap-0.5 rounded-md px-2 py-1 transition-colors duration-150 ease-out",
voted
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-secondary hover:text-foreground",
)}
>
<ChevronUp className="size-4" />
<span className="text-xs font-semibold tabular-nums">
{count}
</span>
</button>
<div className="min-w-0 flex-1">
<div className="flex items-start gap-1.5">
{t.pinned && (
<Pin className="mt-0.5 size-3.5 shrink-0 text-warning" />
)}
<p className="text-sm font-medium leading-snug text-balance">
{t.title}
</p>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{t.author}
{t.instructor && (
<span className="flex items-center gap-0.5 rounded-full bg-primary/10 px-1.5 py-px font-medium text-primary">
<GraduationCap className="size-3" />
Instructor
</span>
)}
</span>
{t.tag && (
<span className="rounded-full bg-secondary px-1.5 py-px">
{t.tag}
</span>
)}
{t.answered && (
<span className="flex items-center gap-0.5 text-success">
<CheckCircle2 className="size-3" />
Answered
</span>
)}
<span className="ml-auto flex items-center gap-2 tabular-nums">
<span className="flex items-center gap-1">
<MessageSquare className="size-3" />
{t.replies}
</span>
<span>{t.time}</span>
</span>
</div>
</div>
</li>
);
})}
</ul>
</div>
);
}