Redesign comments to GitHub/Gitea style
- Each comment is a bordered card with a distinct header bar (author name, clickable relative timestamp, hover-to-reveal delete) and a body section - Subtle spine line connects comments in the avatar column - Composer matches card style: same header bar for Write/Preview tabs, transparent textarea inside, submit row with border-top - Comment timestamps default to relative, click to toggle absolute (mirrors sidebar date toggle pattern) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,7 @@ export default function TicketDetail() {
|
|||||||
const [editingSeverity, setEditingSeverity] = useState(false)
|
const [editingSeverity, setEditingSeverity] = useState(false)
|
||||||
const [editingAssignee, setEditingAssignee] = useState(false)
|
const [editingAssignee, setEditingAssignee] = useState(false)
|
||||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set())
|
||||||
|
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const toggleDate = (key: string) =>
|
const toggleDate = (key: string) =>
|
||||||
setExpandedDates((prev) => {
|
setExpandedDates((prev) => {
|
||||||
@@ -86,6 +87,13 @@ export default function TicketDetail() {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleCommentDate = (id: string) =>
|
||||||
|
setExpandedCommentDates((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
const isAdmin = authUser?.role === 'ADMIN'
|
const isAdmin = authUser?.role === 'ADMIN'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -313,107 +321,110 @@ export default function TicketDetail() {
|
|||||||
|
|
||||||
{/* ── Comments ── */}
|
{/* ── Comments ── */}
|
||||||
{tab === 'comments' && (
|
{tab === 'comments' && (
|
||||||
<div>
|
<div className="p-6 space-y-4">
|
||||||
{ticket.comments && ticket.comments.length > 0 ? (
|
{ticket.comments && ticket.comments.length > 0 ? (
|
||||||
<div className="divide-y divide-gray-800">
|
ticket.comments.map((comment, i) => (
|
||||||
{ticket.comments.map((comment) => (
|
<div key={comment.id} className="flex gap-3 group">
|
||||||
<div key={comment.id} className="p-6 group">
|
{/* Avatar + spine */}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex flex-col items-center">
|
||||||
<Avatar name={comment.author.displayName} size="md" />
|
<Avatar name={comment.author.displayName} size="md" />
|
||||||
<div className="flex-1 min-w-0">
|
{i < (ticket.comments!.length - 1) && (
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex-1 w-px bg-gray-800 mt-2" />
|
||||||
<div className="flex items-center gap-2">
|
)}
|
||||||
<span className="text-sm font-semibold text-gray-200">
|
</div>
|
||||||
{comment.author.displayName}
|
{/* Card */}
|
||||||
</span>
|
<div className="flex-1 min-w-0 border border-gray-700 rounded-lg overflow-hidden">
|
||||||
<span className="text-xs text-gray-500">
|
<div className="flex items-center justify-between px-4 py-2 bg-gray-800/60 border-b border-gray-700">
|
||||||
{format(new Date(comment.createdAt), 'MMM d, yyyy · HH:mm')}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="text-sm font-semibold text-gray-200">
|
||||||
</div>
|
{comment.author.displayName}
|
||||||
{(comment.authorId === authUser?.id || isAdmin) && (
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => deleteComment(comment.id)}
|
onClick={() => toggleCommentDate(comment.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
{expandedCommentDates.has(comment.id)
|
||||||
</button>
|
? format(new Date(comment.createdAt), 'MMM d, yyyy HH:mm')
|
||||||
)}
|
: formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
|
||||||
</div>
|
</button>
|
||||||
<div className="prose text-sm text-gray-300">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
||||||
{comment.body}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{(comment.authorId === authUser?.id || isAdmin) && (
|
||||||
|
<button
|
||||||
|
onClick={() => deleteComment(comment.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 prose prose-sm prose-invert text-gray-300 text-sm max-w-none">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{comment.body}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="py-16 text-center text-sm text-gray-600">
|
<div className="py-12 text-center text-sm text-gray-600">
|
||||||
No comments yet
|
No comments yet
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comment composer */}
|
{/* Composer */}
|
||||||
<div className="border-t border-gray-800 p-6">
|
<div className="flex gap-3">
|
||||||
<div className="flex gap-3">
|
<Avatar name={authUser?.displayName ?? '?'} size="md" />
|
||||||
<Avatar name={authUser?.displayName ?? '?'} size="md" />
|
<div className="flex-1 border border-gray-700 rounded-lg overflow-hidden">
|
||||||
<div className="flex-1">
|
<div className="flex gap-4 px-4 bg-gray-800/60 border-b border-gray-700">
|
||||||
<div className="flex gap-4 mb-2 border-b border-gray-800">
|
{(['Write', 'Preview'] as const).map((label) => (
|
||||||
{(['Write', 'Preview'] as const).map((label) => (
|
<button
|
||||||
<button
|
key={label}
|
||||||
key={label}
|
onClick={() => setPreview(label === 'Preview')}
|
||||||
onClick={() => setPreview(label === 'Preview')}
|
className={`text-xs py-2 border-b-2 -mb-px transition-colors ${
|
||||||
className={`text-xs pb-2 border-b-2 -mb-px transition-colors ${
|
(label === 'Preview') === preview
|
||||||
(label === 'Preview') === preview
|
? 'border-blue-500 text-blue-400'
|
||||||
? 'border-blue-500 text-blue-400'
|
: 'border-transparent text-gray-500 hover:text-gray-300'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-300'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{label}
|
||||||
{label}
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={submitComment}>
|
|
||||||
{preview ? (
|
|
||||||
<div className="prose text-sm text-gray-300 min-h-[80px] mb-3 px-1">
|
|
||||||
{commentBody.trim()
|
|
||||||
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
|
|
||||||
: <span className="text-gray-600 italic">Nothing to preview</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<textarea
|
|
||||||
value={commentBody}
|
|
||||||
onChange={(e) => setCommentBody(e.target.value)}
|
|
||||||
placeholder="Leave a comment… Markdown supported"
|
|
||||||
rows={4}
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-600 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none mb-3"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
||||||
e.preventDefault()
|
|
||||||
submitComment(e as unknown as React.FormEvent)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-xs text-gray-600">
|
|
||||||
Markdown supported · Ctrl+Enter to submit
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submittingComment || !commentBody.trim()}
|
|
||||||
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Send size={13} />
|
|
||||||
Comment
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form onSubmit={submitComment} className="p-3">
|
||||||
|
{preview ? (
|
||||||
|
<div className="prose prose-sm prose-invert text-gray-300 min-h-[80px] mb-3 px-1 max-w-none">
|
||||||
|
{commentBody.trim()
|
||||||
|
? <ReactMarkdown remarkPlugins={[remarkGfm]}>{commentBody}</ReactMarkdown>
|
||||||
|
: <span className="text-gray-600 italic">Nothing to preview</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
value={commentBody}
|
||||||
|
onChange={(e) => setCommentBody(e.target.value)}
|
||||||
|
placeholder="Leave a comment… Markdown supported"
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-transparent text-gray-100 placeholder-gray-600 text-sm focus:outline-none resize-none mb-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
submitComment(e as unknown as React.FormEvent)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center border-t border-gray-700 pt-2">
|
||||||
|
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submittingComment || !commentBody.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Send size={13} />
|
||||||
|
Comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user