full comments system

This commit is contained in:
Oliver Bryan
2026-01-21 19:10:28 +00:00
parent 0d2195cab4
commit 8f87fc8acf
28 changed files with 1451 additions and 7 deletions

View File

@@ -0,0 +1,137 @@
import type { IssueCommentResponse } from "@sprint/shared";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button";
import { Textarea } from "@/components/ui/textarea";
import { useCreateIssueComment, useDeleteIssueComment, useIssueComments } from "@/lib/query/hooks";
import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils";
const formatTimestamp = (value?: string | Date | null) => {
if (!value) return "";
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
export function IssueComments({ issueId, className }: { issueId: number; className?: string }) {
const { user } = useSession();
const { data = [], isLoading } = useIssueComments(issueId);
const createComment = useCreateIssueComment();
const deleteComment = useDeleteIssueComment();
const [body, setBody] = useState("");
const [deletingId, setDeletingId] = useState<number | null>(null);
const sortedComments = useMemo(() => {
return [...data].sort((a, b) => {
const aDate =
a.Comment.createdAt instanceof Date ? a.Comment.createdAt : new Date(a.Comment.createdAt ?? 0);
const bDate =
b.Comment.createdAt instanceof Date ? b.Comment.createdAt : new Date(b.Comment.createdAt ?? 0);
return aDate.getTime() - bDate.getTime();
});
}, [data]);
const handleSubmit = async () => {
const trimmed = body.trim();
if (!trimmed) return;
try {
await createComment.mutateAsync({
issueId,
body: trimmed,
});
setBody("");
} catch (error) {
toast.error(`Error adding comment: ${parseError(error as Error)}`);
}
};
const handleDelete = async (comment: IssueCommentResponse) => {
setDeletingId(comment.Comment.id);
try {
await deleteComment.mutateAsync({ id: comment.Comment.id });
} catch (error) {
toast.error(`Error deleting comment: ${parseError(error as Error)}`);
} finally {
setDeletingId(null);
}
};
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-center justify-between">
<span className="text-sm font-600">Comments</span>
<span className="text-xs text-muted-foreground">{sortedComments.length}</span>
</div>
<div className="flex gap-2">
<Textarea
value={body}
onChange={(event) => setBody(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void handleSubmit();
}
}}
placeholder="Leave a comment..."
className="text-sm resize-none !bg-background h-"
disabled={createComment.isPending}
/>
<Button
size="lg"
onClick={handleSubmit}
disabled={createComment.isPending || body.trim() === ""}
className="px-4"
>
{createComment.isPending ? "Posting..." : "Post comment"}
</Button>
</div>
<div className="flex flex-col gap-2">
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading comments...</div>
) : sortedComments.length === 0 ? (
<div className="text-sm text-muted-foreground">No comments yet.</div>
) : (
sortedComments.map((comment) => {
const isAuthor = user?.id === comment.Comment.userId;
const timestamp = formatTimestamp(comment.Comment.createdAt);
return (
<div key={comment.Comment.id} className="border border-border/60 bg-muted/20 p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<SmallUserDisplay user={comment.User} className="text-sm" />
{timestamp ? (
<span className="text-[11px] text-muted-foreground">{timestamp}</span>
) : null}
</div>
{isAuthor ? (
<IconButton
variant="ghost"
onClick={() => handleDelete(comment)}
disabled={deletingId === comment.Comment.id}
title="Delete comment"
>
<Icon icon="trash" className="size-4" />
</IconButton>
) : null}
</div>
<p className="text-sm whitespace-pre-wrap pt-2">{comment.Comment.body}</p>
</div>
);
})
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { IssueResponse, SprintRecord, UserRecord } from "@sprint/shared";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { IssueComments } from "@/components/issue-comments";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useSession } from "@/components/session-provider";
import SmallSprintDisplay from "@/components/small-sprint-display";
@@ -317,7 +318,7 @@ export function IssueDetails({
</div>
)}
<div className="flex flex-col w-full p-2 py-2 gap-2">
<div className="flex flex-col w-full p-2 py-2 gap-2 max-h-[75vh] overflow-y-scroll">
<div className="flex gap-2">
<StatusSelect
statuses={statuses}
@@ -373,7 +374,7 @@ export function IssueDetails({
}}
placeholder="Add a description..."
disabled={isSavingDescription}
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background"
className="text-sm border-input/50 hover:border-input focus:border-input resize-none !bg-background min-h-fit"
/>
) : (
<Button
@@ -388,7 +389,6 @@ export function IssueDetails({
Add description
</Button>
)}
<div className="flex items-center gap-2">
<span className="text-sm">Sprint:</span>
<SprintSelect sprints={sprints} value={sprintId} onChange={handleSprintChange} />
@@ -423,6 +423,8 @@ export function IssueDetails({
</div>
)}
<IssueComments issueId={issueData.Issue.id} className="pt-2" />
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}

View File

@@ -27,7 +27,7 @@ export function IssuesTable({
params.set("o", selectedOrganisation.Organisation.slug.toLowerCase());
params.set("p", selectedProject.Project.key.toLowerCase());
params.set("i", issueNumber.toString());
return `/app?${params.toString()}`;
return `/issues?${params.toString()}`;
};
const handleLinkClick = (e: React.MouseEvent) => {

View File

@@ -56,7 +56,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// unauthorized
@@ -94,7 +94,7 @@ export default function LogInForm() {
const data = await res.json();
setCsrfToken(data.csrfToken);
setUser(data.user);
const next = searchParams.get("next") || "/app";
const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true });
}
// bad request (probably a bad user input)