395 lines
20 KiB
TypeScript
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;
|