gitdataai/src/app/project/issue-detail/IssueDetailPage.tsx

395 lines
20 KiB
TypeScript

import React, {useState} from "react";
import {useParams, useNavigate} from "react-router-dom";
import {useCurrentUserQuery} from "@/hooks/useAuth";
import {
useCreateCommentMutation,
useDeleteCommentMutation,
useIssueCommentsQuery,
useIssueDetailQuery,
useUpdateCommentMutation,
} from "@/hooks/useIssueDetailQuery";
import {
useCloseIssueMutation,
useReopenIssueMutation,
useUpdateIssueMutation,
useDeleteIssueMutation,
} from "@/hooks/useIssueExtraQuery";
import {LoadingState} from "@/components/ui/LoadingState";
import {ErrorState} from "@/components/ui/ErrorState";
import {IrRenderer} from "@/lib/ir/renderer";
import {extractIrNodes} from "@/lib/ir/parser";
import {Button} from "@/components/ui/button";
import {Textarea} from "@/components/ui/textarea";
import {Input} from "@/components/ui/input";
import {Loader2, MessageSquare, Pencil, Trash2, Lock, Unlock} from "lucide-react";
import type {IssueCommentResponse} from "@/client/model";
import {IssueSidebar} from "./IssueSidebar";
import {ReactionBar} from "./ReactionBar";
export function IssueDetailPage() {
const {projectName, issueNumber} = useParams<{
projectName: string;
issueNumber: string;
}>();
const number = issueNumber ? Number(issueNumber) : 0;
const {data: currentUser} = useCurrentUserQuery();
const navigate = useNavigate();
const {
data: issueDetail,
isLoading: detailLoading,
error: detailError,
refetch: refetchDetail,
} = useIssueDetailQuery({projectName: projectName!, issueNumber: number});
const {
data: comments = [],
} = useIssueCommentsQuery({projectName: projectName!, issueNumber: number});
const closeIssue = useCloseIssueMutation();
const reopenIssue = useReopenIssueMutation();
const updateIssue = useUpdateIssueMutation();
const deleteIssue = useDeleteIssueMutation();
const createComment = useCreateCommentMutation();
const updateComment = useUpdateCommentMutation();
const deleteComment = useDeleteCommentMutation();
const [newCommentBody, setNewCommentBody] = useState("");
const [editingId, setEditingId] = useState<number | null>(null);
const [editBody, setEditBody] = useState("");
// Issue edit state
const [isEditingIssue, setIsEditingIssue] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editIssueBody, setEditIssueBody] = useState("");
const handleRetry = () => {
refetchDetail();
};
const handleClose = () => {
if (!projectName || !number) return;
closeIssue.mutate({ projectName, issueNumber: number }, { onSuccess: () => refetchDetail() });
};
const handleReopen = () => {
if (!projectName || !number) return;
reopenIssue.mutate({ projectName, issueNumber: number }, { onSuccess: () => refetchDetail() });
};
const handleDelete = () => {
if (!projectName || !number) return;
if (!window.confirm("Are you sure you want to delete this issue?")) return;
deleteIssue.mutate({ projectName, issueNumber: number }, { onSuccess: () => navigate(`/${projectName}/issues`) });
};
const startEditIssue = () => {
if (!issueDetail) return;
setEditTitle(issueDetail.title);
setEditIssueBody(issueDetail.body ?? "");
setIsEditingIssue(true);
};
const saveEditIssue = () => {
if (!projectName || !number) return;
updateIssue.mutate(
{ projectName, issueNumber: number, req: { title: editTitle, body: editIssueBody } },
{ onSuccess: () => { setIsEditingIssue(false); refetchDetail(); } }
);
};
const cancelEditIssue = () => {
setIsEditingIssue(false);
setEditTitle("");
setEditIssueBody("");
};
const handleCreateComment = () => {
if (!projectName || !number || !newCommentBody.trim()) return;
createComment.mutate(
{projectName, issueNumber: number, body: newCommentBody.trim()},
{
onSuccess: () => {
setNewCommentBody("");
},
}
);
};
const handleStartEdit = (comment: IssueCommentResponse) => {
setEditingId(comment.id);
setEditBody(comment.body);
};
const handleCancelEdit = () => {
setEditingId(null);
setEditBody("");
};
const handleSaveEdit = () => {
if (!projectName || !number || editingId === null || !editBody.trim()) return;
updateComment.mutate(
{projectName, issueNumber: number, commentId: editingId, body: editBody.trim()},
{
onSuccess: () => {
setEditingId(null);
setEditBody("");
},
}
);
};
const handleDeleteComment = (commentId: number) => {
if (!projectName || !number) return;
if (!window.confirm("Are you sure you want to delete this comment?")) return;
deleteComment.mutate(
{ projectName, issueNumber: number, commentId },
{ onSuccess: () => refetchComments() }
);
};
const isOwnComment = (comment: IssueCommentResponse) => {
if (!currentUser) return false;
return currentUser.uid === comment.author || currentUser.username === comment.author_username;
};
const isMutating =
createComment.isPending || updateComment.isPending || deleteComment.isPending;
if (detailLoading) {
return (
<div className="h-full flex items-center justify-center">
<LoadingState message="Loading issue..."/>
</div>
);
}
if (detailError || !issueDetail) {
return (
<div className="h-full flex items-center justify-center">
<ErrorState
title="Failed to load issue"
message={detailError?.message || "Issue not found"}
onRetry={handleRetry}
/>
</div>
);
}
return (
<div className="flex h-full overflow-hidden" style={{ backgroundColor: "var(--surface-elevated)" }}>
<div className="flex-1 overflow-y-auto px-6 py-8">
<div className="max-w-4xl mx-auto">
{isEditingIssue ? (
<div className="mb-4 space-y-3">
<Input
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="text-xl font-bold border-none focus-visible:ring-1"
style={{ backgroundColor: "var(--surface-elevated)", "--tw-ring-color": "var(--accent)" } as React.CSSProperties}
/>
<Textarea
value={editIssueBody}
onChange={(e) => setEditIssueBody(e.target.value)}
className="min-h-[120px] border-none focus-visible:ring-1"
style={{ backgroundColor: "var(--surface-elevated)", "--tw-ring-color": "var(--accent)" } as React.CSSProperties}
/>
<div className="flex items-center gap-2">
<Button size="sm" style={{ backgroundColor: "var(--status-online)" }} onClick={saveEditIssue} disabled={updateIssue.isPending}>
{updateIssue.isPending ? <Loader2 className="w-4 h-4 animate-spin"/> : "Save"}
</Button>
<Button size="sm" variant="ghost" onClick={cancelEditIssue}>Cancel</Button>
</div>
</div>
) : (
<h1 className="text-3xl font-bold mb-2" style={{ color: "var(--text-primary)" }}>
<span className="font-normal mr-2" style={{ color: "var(--text-muted)" }}>#{issueDetail.number}</span>
{issueDetail.title}
</h1>
)}
<div className="flex items-center gap-3 mb-8 text-sm">
<span
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider"
style={{
backgroundColor: issueDetail.state === "open" ? "var(--status-online)" : "var(--accent)",
color: "var(--text-inverse)"
}}
>
{issueDetail.state}
</span>
<span style={{ color: "var(--text-muted)" }}>
<span className="font-bold" style={{ color: "var(--text-primary)" }}>{issueDetail.author_username}</span> opened this issue on {new Date(issueDetail.created_at).toLocaleDateString()}
</span>
<span style={{ color: "var(--text-muted)" }} className="flex items-center gap-1">
<span className="w-1 h-1 rounded-full"/>
{comments.length} comments
</span>
<div className="flex items-center gap-2 ml-auto">
<Button variant="ghost" size="sm" onClick={startEditIssue}>
<Pencil className="w-4 h-4"/> Edit
</Button>
{issueDetail.state === "open" ? (
<Button variant="outline" size="sm" onClick={handleClose} disabled={closeIssue.isPending}>
<Lock className="w-4 h-4 mr-1"/> Close
</Button>
) : (
<Button variant="outline" size="sm" onClick={handleReopen} disabled={reopenIssue.isPending}>
<Unlock className="w-4 h-4 mr-1"/> Reopen
</Button>
)}
<Button variant="ghost" size="sm" className="text-destructive" onClick={handleDelete} disabled={deleteIssue.isPending}>
<Trash2 className="w-4 h-4"/> Delete
</Button>
</div>
</div>
<div className="space-y-6">
{/* Description */}
<div
className="rounded-lg border overflow-hidden"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<div
className="border-b px-4 py-2 flex items-center justify-between"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<span
className="text-xs font-bold" style={{ color: "var(--text-primary)" }}>{issueDetail.author_username} commented</span>
</div>
<div className="p-4">
{issueDetail.body ? (
<IrRenderer nodes={extractIrNodes(issueDetail.body)}/>
) : (
<p className="text-sm italic" style={{ color: "var(--text-muted)" }}>No description provided.</p>
)}
<ReactionBar projectName={projectName!} issueNumber={number}/>
</div>
</div>
<div className="space-y-4 pt-4 border-t" style={{ borderColor: "var(--border-default)" }}>
<h2 className="text-lg font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
<MessageSquare className="w-5 h-5"/>
Conversation
</h2>
{comments.length === 0 ? (
<p className="text-sm text-center py-8" style={{ color: "var(--text-muted)" }}>
No comments yet. Start the discussion!
</p>
) : (
<div className="space-y-4">
{comments.map((comment: IssueCommentResponse) => (
<div
key={comment.id}
className="rounded-lg border overflow-hidden"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}
>
<div
className="border-b px-4 py-2 flex items-center justify-between"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<div className="flex items-center gap-2">
<span
className="text-xs font-bold" style={{ color: "var(--text-primary)" }}>{comment.author_username}</span>
<span className="text-[10px]">commented on {new Date(comment.created_at).toLocaleDateString()}</span>
{comment.updated_at !== comment.created_at && (
<span
className="text-[10px] italic"> edited</span>
)}
</div>
{isOwnComment(comment) && editingId !== comment.id && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon-sm"
onClick={() => handleStartEdit(comment)}
disabled={isMutating}>
<Pencil className="w-3 h-3"/>
</Button>
<Button variant="ghost" size="icon-sm"
onClick={() => handleDeleteComment(comment.id)}
disabled={isMutating}>
<Trash2 className="w-3 h-3 text-destructive"/>
</Button>
</div>
)}
</div>
<div className="p-4">
{editingId === comment.id ? (
<div className="space-y-3">
<Textarea
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
className="min-h-[100px] text-sm border-none focus-visible:ring-1"
style={{ backgroundColor: "var(--surface-elevated)", "--tw-ring-color": "var(--accent)" } as React.CSSProperties}
disabled={isMutating}
/>
<div className="flex items-center gap-2">
<Button size="sm"
className="hover:brightness-90"
style={{ backgroundColor: "var(--status-online)" }}
onClick={handleSaveEdit}
disabled={isMutating || !editBody.trim()}>
Save Changes
</Button>
<Button variant="ghost" size="sm" onClick={handleCancelEdit}
disabled={isMutating}>
Cancel
</Button>
</div>
</div>
) : (
<IrRenderer nodes={extractIrNodes(comment.body)}/>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* New Comment */}
<div
className="mt-8 rounded-lg border overflow-hidden"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<div
className="border-b px-4 py-2"
style={{ backgroundColor: "var(--surface-elevated)", borderColor: "var(--border-default)" }}>
<span
className="text-xs font-bold">Write a comment</span>
</div>
<div className="p-4 space-y-4">
<Textarea
value={newCommentBody}
onChange={(e) => setNewCommentBody(e.target.value)}
placeholder="Write a comment... (Markdown supported)"
className="min-h-[120px] text-sm border-none focus-visible:ring-1"
style={{ backgroundColor: "var(--surface-elevated)", "--tw-ring-color": "var(--accent)" } as React.CSSProperties}
disabled={isMutating}
/>
<div className="flex items-center justify-end">
<Button
size="sm"
className="px-6"
style={{ backgroundColor: "var(--accent)" }}
onClick={handleCreateComment}
disabled={isMutating || !newCommentBody.trim()}
>
{isMutating ? <Loader2 className="w-4 h-4 animate-spin"/> : "Comment"}
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="w-[300px] border-l p-6 overflow-y-auto"
style={{ borderColor: "var(--border-default)", backgroundColor: "var(--surface-elevated)" }}>
<IssueSidebar projectName={projectName!} issueNumber={number}/>
</div>
</div>
);
}
export default IssueDetailPage;