feat: task comments/discussion system
- New task_comments table (separate from progress notes) - Backend: GET/POST/DELETE /api/tasks/:id/comments with session + bearer auth - TaskComments component on TaskPage (full-page view) with markdown support, author avatars, delete own comments, 30s polling - CompactComments in TaskDetailPanel (side panel) with last 3 + expand - Comment API functions in frontend lib/api.ts
This commit is contained in:
207
frontend/src/components/TaskComments.tsx
Normal file
207
frontend/src/components/TaskComments.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { fetchComments, addComment, deleteComment, type TaskComment } from "../lib/api";
|
||||
import { useCurrentUser } from "../hooks/useCurrentUser";
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
month: "short", day: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// Avatar color based on name
|
||||
function avatarColor(name: string): string {
|
||||
const colors = [
|
||||
"bg-blue-500", "bg-green-500", "bg-purple-500", "bg-pink-500",
|
||||
"bg-indigo-500", "bg-teal-500", "bg-cyan-500", "bg-rose-500",
|
||||
];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
function avatarInitial(name: string): string {
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
const proseClasses = "prose prose-sm prose-gray dark:prose-invert max-w-none [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ol]:mb-1 [&_li]:mb-0 [&_code]:bg-gray-200 dark:[&_code]:bg-gray-700 [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_a]:text-amber-600 dark:[&_a]:text-amber-400 [&_a]:underline";
|
||||
|
||||
export function TaskComments({ taskId }: { taskId: string }) {
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [commentText, setCommentText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const loadComments = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchComments(taskId);
|
||||
setComments(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load comments:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadComments();
|
||||
// Poll for new comments every 30s
|
||||
const interval = setInterval(loadComments, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadComments]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = commentText.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addComment(taskId, text);
|
||||
setCommentText("");
|
||||
await loadComments();
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "42px";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to add comment:", e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
setDeletingId(commentId);
|
||||
try {
|
||||
await deleteComment(taskId, commentId);
|
||||
setComments((prev) => prev.filter((c) => c.id !== commentId));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete comment:", e);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const autoResize = () => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "42px";
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + "px";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-3">
|
||||
💬 Discussion {comments.length > 0 && (
|
||||
<span className="text-gray-300 dark:text-gray-600 ml-1">({comments.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
{/* Comment input */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-3">
|
||||
{user && (
|
||||
<div className={`w-8 h-8 rounded-full ${avatarColor(user.name)} flex items-center justify-center text-white text-sm font-bold shrink-0 mt-1`}>
|
||||
{avatarInitial(user.name)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commentText}
|
||||
onChange={(e) => { setCommentText(e.target.value); autoResize(); }}
|
||||
placeholder="Leave a comment..."
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 focus:border-amber-300 dark:focus:border-amber-700 resize-none placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
style={{ minHeight: "42px" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500">Markdown supported · ⌘+Enter to submit</span>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!commentText.trim() || submitting}
|
||||
className="text-xs px-3 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "Posting..." : "Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments list */}
|
||||
{loading ? (
|
||||
<div className="text-center text-gray-400 dark:text-gray-500 py-6 text-sm">Loading comments...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 dark:text-gray-500 italic py-6 text-center border-2 border-dashed border-gray-100 dark:border-gray-800 rounded-lg">
|
||||
No comments yet — be the first to chime in
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => {
|
||||
const isOwn = user && comment.authorId === user.id;
|
||||
const isHammer = comment.authorId === "hammer";
|
||||
return (
|
||||
<div key={comment.id} className="flex gap-3 group">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0 mt-0.5 ${
|
||||
isHammer
|
||||
? "bg-amber-500 text-white"
|
||||
: `${avatarColor(comment.authorName)} text-white`
|
||||
}`}>
|
||||
{isHammer ? "🔨" : avatarInitial(comment.authorName)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-sm font-semibold ${
|
||||
isHammer ? "text-amber-700 dark:text-amber-400" : "text-gray-800 dark:text-gray-200"
|
||||
}`}>
|
||||
{comment.authorName}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400 dark:text-gray-500" title={formatDate(comment.createdAt)}>
|
||||
{timeAgo(comment.createdAt)}
|
||||
</span>
|
||||
{isOwn && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={deletingId === comment.id}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-300 dark:text-gray-600 hover:text-red-400 dark:hover:text-red-400 transition text-xs"
|
||||
title="Delete comment"
|
||||
>
|
||||
{deletingId === comment.id ? "..." : "×"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-sm text-gray-700 dark:text-gray-300 leading-relaxed ${proseClasses}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{comment.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask, fetchComments, addComment, type TaskComment } from "../lib/api";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
function CompactComments({ taskId }: { taskId: string }) {
|
||||
const [comments, setComments] = useState<TaskComment[]>([]);
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments(taskId).then(setComments).catch(() => {});
|
||||
}, [taskId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!text.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await addComment(taskId, text.trim());
|
||||
setComments((prev) => [...prev, comment]);
|
||||
setText("");
|
||||
} catch (e) {
|
||||
console.error("Failed to add comment:", e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recentComments = expanded ? comments : comments.slice(-3);
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 pt-4 mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
💬 Discussion {comments.length > 0 && <span className="text-gray-300 dark:text-gray-600">({comments.length})</span>}
|
||||
</h3>
|
||||
{comments.length > 3 && !expanded && (
|
||||
<button onClick={() => setExpanded(true)} className="text-[10px] text-amber-600 dark:text-amber-400 hover:text-amber-700 dark:hover:text-amber-300 font-medium">
|
||||
Show all ({comments.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
{recentComments.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{recentComments.map((c) => (
|
||||
<div key={c.id} className="text-xs">
|
||||
<span className={`font-semibold ${c.authorId === "hammer" ? "text-amber-700 dark:text-amber-400" : "text-gray-700 dark:text-gray-300"}`}>
|
||||
{c.authorName}
|
||||
</span>
|
||||
<span className="text-gray-400 dark:text-gray-500 ml-1.5">
|
||||
{timeAgo(c.createdAt)}
|
||||
</span>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-0.5 leading-relaxed">{c.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
className="flex-1 text-xs border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim() || submitting}
|
||||
className="text-xs px-2.5 py-1.5 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 transition disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
||||
>
|
||||
{submitting ? "..." : "Post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
|
||||
const actions = statusActions[task.status] || [];
|
||||
const isActive = task.status === "active";
|
||||
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Comments */}
|
||||
<CompactComments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel Bar */}
|
||||
|
||||
@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── Comments API ───
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
taskId: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export async function fetchComments(taskId: string): Promise<TaskComment[]> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error("Failed to fetch comments");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function addComment(taskId: string, content: string): Promise<TaskComment> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to add comment");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteComment(taskId: string, commentId: string): Promise<void> {
|
||||
const res = await fetch(`${BASE}/${taskId}/comments/${commentId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete comment");
|
||||
}
|
||||
|
||||
// Admin API
|
||||
export async function fetchUsers(): Promise<any[]> {
|
||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
||||
|
||||
@@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Task, TaskStatus, Project } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { TaskComments } from "../components/TaskComments";
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
@@ -598,6 +599,8 @@ export function TaskPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Comments / Discussion */}
|
||||
<TaskComments taskId={task.id} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
|
||||
Reference in New Issue
Block a user