$issueNumber.tsx (6.88 KB)
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { formatDistanceToNow } from "date-fns";
import { CircleCheckIcon, CircleDotIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { createCommentForIssueFn } from "@/api/comments";
import {
  getIssueByRepoAndNumberOptions,
  updateIssueStatusFn,
} from "@/api/issues";
import { NotFoundComponent } from "@/components/404-components";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";

export const Route = createFileRoute(
  "/$owner/$repo/_layout/issues/$issueNumber"
)({
  component: RouteComponent,
  notFoundComponent: NotFoundComponent,
  loader: async ({ params, context: { queryClient } }) => {
    await queryClient.ensureQueryData(
      getIssueByRepoAndNumberOptions({
        owner: params.owner,
        repo: params.repo,
        number: Number(params.issueNumber),
      })
    );
  },
});

function RouteComponent() {
  const params = Route.useParams();
  const { data: issue, refetch: refetchIssue } = useSuspenseQuery(
    getIssueByRepoAndNumberOptions({
      owner: params.owner,
      repo: params.repo,
      number: Number(params.issueNumber),
    })
  );
  const [commentBody, setCommentBody] = useState("");

  const addCommentMutation = useMutation({
    mutationFn: async (body: string) =>
      await createCommentForIssueFn({
        data: {
          issueId: issue.id,
          body,
        },
      }),
    onSuccess: async () => {
      setCommentBody("");
      await refetchIssue();
    },
    onError: (err) => {
      console.error("Error creating comment:", err);
      toast.error(err.message);
    },
  });

  const updateIssueStatusMutation = useMutation({
    mutationFn: async (status: "open" | "closed") =>
      await updateIssueStatusFn({
        data: {
          issueId: issue.id,
          status,
        },
      }),

    onError: (err) => {
      console.error("Error updating issue status:", err);
      toast.error(err.message);
    },
  });

  const handleSubmitComment = () => {
    if (!commentBody.trim()) {
      return;
    }

    addCommentMutation.mutate(commentBody);
  };

  const handleCancel = () => {
    setCommentBody("");
  };

  const handleToggleStatus = () => {
    const newStatus = issue.status === "open" ? "closed" : "open";
    updateIssueStatusMutation.mutate(newStatus);
  };

  const isSubmitting = addCommentMutation.isPending;

  const statusLabel = issue.status === "open" ? "Open" : "Closed";

  const issueIconMap = {
    open: CircleDotIcon,
    closed: CircleCheckIcon,
  };

  const CurrentStatusIcon = issueIconMap[issue.status];
  const InverseStatusIcon =
    issue.status === "open" ? issueIconMap.closed : issueIconMap.open;

  return (
    <div className="mx-auto max-w-5xl space-y-6">
      {/* Header */}
      <div className="space-y-2">
        <h1 className="font-semibold text-2xl leading-tight">{issue.title}</h1>
        <Badge
          className={cn("text-primary", {
            "bg-green-700": issue.status === "open",
            "bg-purple-600": issue.status === "closed",
          })}
        >
          <CurrentStatusIcon className="size-4" />
          {statusLabel}
        </Badge>
      </div>

      {/* Main Content */}
      <div className="space-y-4">
        {/* Original Comment */}
        <Comment
          _creationTime={issue.createdAt.getTime()}
          content={issue.body ?? undefined}
          creatorUsername={issue.creatorUsername}
          type="description"
        />

        {/* Comments Section */}
        <h3 className="font-semibold text-sm">
          Comments ({issue.comments.length})
        </h3>
        {issue.comments.length > 0 ? (
          <div className="space-y-4">
            {issue.comments.map((comment) => (
              <Comment
                _creationTime={comment.createdAt.getTime()}
                content={comment.body}
                creatorUsername={comment.authorUsername}
                key={comment.id}
                type="comment"
              />
            ))}
          </div>
        ) : (
          <div className="flex items-center justify-center rounded-lg border border-dashed py-8">
            <p className="text-muted-foreground text-sm">
              No comments yet. Be the first to comment!
            </p>
          </div>
        )}

        {/* Add Comment */}
        <div className="space-y-3">
          <Textarea
            className="resize-none"
            disabled={isSubmitting}
            onChange={(e) => setCommentBody(e.target.value)}
            placeholder="Add a comment..."
            rows={4}
            value={commentBody}
          />
          <div className="flex items-center justify-between gap-2">
            {/* TODO: Check if user is the creator of the issue or the repository owner */}
            {issue.creatorId === "" && (
              <Button
                loading={updateIssueStatusMutation.isPending}
                onClick={handleToggleStatus}
                variant="outline"
              >
                <InverseStatusIcon
                  className={cn("size-4", {
                    "text-purple-400": issue.status === "open",
                    "text-green-500": issue.status === "closed",
                  })}
                />
                {issue.status === "open" ? "Close issue" : "Reopen issue"}
              </Button>
            )}
            <div className="flex gap-2">
              <Button
                disabled={isSubmitting || !commentBody.trim()}
                onClick={handleCancel}
                variant="outline"
              >
                Cancel
              </Button>
              <Button
                loading={isSubmitting}
                onClick={handleSubmitComment}
                type="button"
              >
                Comment
              </Button>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

type CommentProp = {
  type: "description" | "comment";
  content: string | undefined;
  creatorUsername: string;
  _creationTime: number;
};

export function Comment({
  type = "comment",
  content,
  creatorUsername,
  _creationTime,
}: CommentProp) {
  return (
    <div className="overflow-hidden rounded-lg border bg-card">
      <div className="flex items-center gap-2 border-b px-3 py-2">
        <p className="font-semibold text-sm">{creatorUsername}</p>
        <p className="text-muted-foreground text-xs">
          {type === "description" ? "created" : "commented"}{" "}
          {formatDistanceToNow(new Date(_creationTime), {
            addSuffix: true,
          })}
        </p>
      </div>

      <div className="prose prose-sm dark:prose-invert bg-background px-3 py-2">
        {content ?? "No content provided"}
      </div>
    </div>
  );
}