# Post board

> A feedback board's posts ranked by votes, with an upvote toggle, the roadmap status, moderation state and a New post dialog.

Source: https://bettersupabase.com/docs/registry/ui-recipes/post-board

{/* Generated by scripts/build-recipes.ts from the example apps. Edit the examples or the script, then run `pnpm recipes`. */}

It loads its data on the server through the block's client; the [posts block](https://bettersupabase.com/blocks/posts) must be installed (`npx better-supabase add posts`). Its page on bettersupabase.com is [/recipes/post-board](https://bettersupabase.com/recipes/post-board).

Web, with shadcn components:

```bash
npx shadcn add alert badge button card dialog empty field input skeleton textarea
```

```tsx title="apps/examples/nextjs/src/features/community/components/post-board.tsx"
const statusVariants = {
  open: "outline",
  under_review: "info-light",
  planned: "primary-light",
  in_progress: "warning-light",
  done: "success-light",
  closed: "secondary",
} as const satisfies Record<PostStatus, string>;

const moderationVariants = {
  visible: null,
  pending: "warning-light",
  hidden: "destructive-light",
  removed: "destructive-light",
} as const satisfies Record<PostModeration, string | null>;

/** Render inside `<Suspense>`: the board's posts, top voted first. */
export async function PostBoard() {
  const [view, session, t] = await Promise.all([
    getBoard(),
    getSession(),
    getExtracted("community"),
  ]);

  if (view.kind === "no-organization") {
    return null;
  }

  if (view.kind === "error") {
    return (
      <Alert variant="destructive">
        <TriangleAlertIcon />
        <AlertTitle>{t("The board didn't load")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <form action={retry}>
            <Button size="sm" variant="outline" type="submit">
              {t("Retry")}
            </Button>
          </form>
        </AlertAction>
      </Alert>
    );
  }

  const board = view.data;

  if (board === null) {
    const manage = can(session, "posts.manage");

    return (
      <Empty className="border">
        <EmptyHeader>
          <EmptyMedia variant="icon">
            <MessageSquareTextIcon />
          </EmptyMedia>
          <EmptyTitle>{t("No board yet")}</EmptyTitle>
          <EmptyDescription>
            {manage
              ? t("Open a feedback board where members post and vote on ideas.")
              : t("An admin opens the board your team posts on.")}
          </EmptyDescription>
        </EmptyHeader>
        <EmptyContent>
          {manage ? (
            <CreateBoardButton />
          ) : (
            <Link href="/" className={buttonVariants({ variant: "outline" })}>
              {t("Back to the dashboard")}
            </Link>
          )}
        </EmptyContent>
      </Empty>
    );
  }

  const write = can(session, "posts.write");

  const statusLabels: Record<PostStatus, string> = {
    open: t("Open"),
    under_review: t("Under review"),
    planned: t("Planned"),
    in_progress: t("In progress"),
    done: t("Done"),
    closed: t("Closed"),
  };

  const moderationLabels: Record<PostModeration, string> = {
    visible: t("Visible"),
    pending: t("Awaiting review"),
    hidden: t("Hidden"),
    removed: t("Removed"),
  };

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between gap-2">
        <h2 className="text-sm font-medium">{board.name}</h2>
        {write ? <NewPostDialog boardId={board.id} /> : null}
      </div>
      {board.posts.length === 0 ? (
        <Empty className="border">
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <MessageSquareIcon />
            </EmptyMedia>
            <EmptyTitle>{t("No posts yet")}</EmptyTitle>
            <EmptyDescription>
              {t("Post the first idea for others to vote on.")}
            </EmptyDescription>
          </EmptyHeader>
          {write ? (
            <EmptyContent>
              <NewPostDialog boardId={board.id} />
            </EmptyContent>
          ) : null}
        </Empty>
      ) : (
        <ul className="flex flex-col gap-3">
          {board.posts.map((post) => {
            const moderation = moderationVariants[post.moderation];

            return (
              <li key={post.id}>
                <Card size="sm">
                  <CardContent className="flex items-start gap-3">
                    <VoteButton
                      postId={post.id}
                      score={post.score}
                      voted={post.vote > 0}
                      disabled={post.moderation !== "visible"}
                    />
                    <div className="flex min-w-0 flex-1 flex-col gap-1">
                      <div className="flex flex-wrap items-center gap-2">
                        {post.pinned ? (
                          <PinIcon
                            aria-label={t("Pinned")}
                            className="size-4 text-muted-foreground"
                          />
                        ) : null}
                        <span className="text-sm font-medium">
                          {post.title}
                        </span>
                        <Badge variant={statusVariants[post.status]}>
                          {statusLabels[post.status]}
                        </Badge>
                        {moderation === null ? null : (
                          <Badge variant={moderation}>
                            {moderationLabels[post.moderation]}
                          </Badge>
                        )}
                      </div>
                      {post.body ? (
                        <p className="line-clamp-2 text-sm text-muted-foreground">
                          {post.body}
                        </p>
                      ) : null}
                      <div className="flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
                        <span className="flex items-center gap-1">
                          <MessageSquareIcon aria-hidden className="size-3.5" />
                          {t("{count} replies", {
                            count: String(post.replyCount),
                          })}
                        </span>
                        {post.locked ? (
                          <span className="flex items-center gap-1">
                            <LockIcon aria-hidden className="size-3.5" />
                            {t("Locked")}
                          </span>
                        ) : null}
                      </div>
                    </div>
                  </CardContent>
                </Card>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}
```