mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
full comments system
This commit is contained in:
137
packages/frontend/src/components/issue-comments.tsx
Normal file
137
packages/frontend/src/components/issue-comments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user