EdTech

Discussion Forum

A course discussion thread list with interactive upvotes, pinned and answered badges, category tags, and an instructor badge on author rows.

Install

npx shadcn@latest add @paragon/discussion-forum

discussion-forum.tsx

"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>
  );
}