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:
@@ -103,6 +103,20 @@ export const tasks = pgTable("tasks", {
|
|||||||
export type Task = typeof tasks.$inferSelect;
|
export type Task = typeof tasks.$inferSelect;
|
||||||
export type NewTask = typeof tasks.$inferInsert;
|
export type NewTask = typeof tasks.$inferInsert;
|
||||||
|
|
||||||
|
// ─── Comments ───
|
||||||
|
|
||||||
|
export const taskComments = pgTable("task_comments", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
taskId: uuid("task_id").notNull().references(() => tasks.id, { onDelete: "cascade" }),
|
||||||
|
authorId: text("author_id"), // BetterAuth user ID, or "hammer" for API, null for anonymous
|
||||||
|
authorName: text("author_name").notNull(),
|
||||||
|
content: text("content").notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TaskComment = typeof taskComments.$inferSelect;
|
||||||
|
export type NewTaskComment = typeof taskComments.$inferInsert;
|
||||||
|
|
||||||
// ─── BetterAuth tables ───
|
// ─── BetterAuth tables ───
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { taskRoutes } from "./routes/tasks";
|
|||||||
import { adminRoutes } from "./routes/admin";
|
import { adminRoutes } from "./routes/admin";
|
||||||
import { projectRoutes } from "./routes/projects";
|
import { projectRoutes } from "./routes/projects";
|
||||||
import { chatRoutes } from "./routes/chat";
|
import { chatRoutes } from "./routes/chat";
|
||||||
|
import { commentRoutes } from "./routes/comments";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { tasks, users } from "./db/schema";
|
import { tasks, users } from "./db/schema";
|
||||||
@@ -115,6 +116,7 @@ const app = new Elysia()
|
|||||||
})
|
})
|
||||||
|
|
||||||
.use(taskRoutes)
|
.use(taskRoutes)
|
||||||
|
.use(commentRoutes)
|
||||||
.use(projectRoutes)
|
.use(projectRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
.use(chatRoutes)
|
.use(chatRoutes)
|
||||||
|
|||||||
127
backend/src/routes/comments.ts
Normal file
127
backend/src/routes/comments.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { taskComments, tasks } from "../db/schema";
|
||||||
|
import { eq, desc, asc } from "drizzle-orm";
|
||||||
|
import { auth } from "../lib/auth";
|
||||||
|
import { parseTaskIdentifier } from "../lib/utils";
|
||||||
|
|
||||||
|
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||||
|
|
||||||
|
async function requireSessionOrBearer(request: Request, headers: Record<string, string | undefined>) {
|
||||||
|
const authHeader = headers["authorization"];
|
||||||
|
if (authHeader === `Bearer ${BEARER_TOKEN}`) {
|
||||||
|
return { userId: "hammer", userName: "Hammer" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (session?.user) {
|
||||||
|
return { userId: session.user.id, userName: session.user.name || session.user.email };
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTaskId(idOrNumber: string): Promise<string | null> {
|
||||||
|
const parsed = parseTaskIdentifier(idOrNumber);
|
||||||
|
let result;
|
||||||
|
if (parsed.type === "number") {
|
||||||
|
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.taskNumber, parsed.value));
|
||||||
|
} else {
|
||||||
|
result = await db.select({ id: tasks.id }).from(tasks).where(eq(tasks.id, parsed.value));
|
||||||
|
}
|
||||||
|
return result[0]?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commentRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||||
|
.onError(({ error, set }) => {
|
||||||
|
const msg = (error as any)?.message || String(error);
|
||||||
|
if (msg === "Unauthorized") {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (msg === "Task not found") {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Task not found" };
|
||||||
|
}
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal server error" };
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET comments for a task
|
||||||
|
.get(
|
||||||
|
"/:id/comments",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const taskId = await resolveTaskId(params.id);
|
||||||
|
if (!taskId) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const comments = await db
|
||||||
|
.select()
|
||||||
|
.from(taskComments)
|
||||||
|
.where(eq(taskComments.taskId, taskId))
|
||||||
|
.orderBy(asc(taskComments.createdAt));
|
||||||
|
|
||||||
|
return comments;
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST add comment to a task
|
||||||
|
.post(
|
||||||
|
"/:id/comments",
|
||||||
|
async ({ params, body, request, headers }) => {
|
||||||
|
const user = await requireSessionOrBearer(request, headers);
|
||||||
|
const taskId = await resolveTaskId(params.id);
|
||||||
|
if (!taskId) throw new Error("Task not found");
|
||||||
|
|
||||||
|
const comment = await db
|
||||||
|
.insert(taskComments)
|
||||||
|
.values({
|
||||||
|
taskId,
|
||||||
|
authorId: user.userId,
|
||||||
|
authorName: body.authorName || user.userName,
|
||||||
|
content: body.content,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return comment[0];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
content: t.String(),
|
||||||
|
authorName: t.Optional(t.String()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE a comment
|
||||||
|
.delete(
|
||||||
|
"/:id/comments/:commentId",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
const user = await requireSessionOrBearer(request, headers);
|
||||||
|
const taskId = await resolveTaskId(params.id);
|
||||||
|
if (!taskId) throw new Error("Task not found");
|
||||||
|
|
||||||
|
// Only allow deleting own comments (or bearer token = admin)
|
||||||
|
const comment = await db
|
||||||
|
.select()
|
||||||
|
.from(taskComments)
|
||||||
|
.where(eq(taskComments.id, params.commentId));
|
||||||
|
|
||||||
|
if (!comment[0]) throw new Error("Task not found");
|
||||||
|
if (comment[0].taskId !== taskId) throw new Error("Task not found");
|
||||||
|
|
||||||
|
// Bearer token can delete any, otherwise must be author
|
||||||
|
const authHeader = headers["authorization"];
|
||||||
|
if (authHeader !== `Bearer ${BEARER_TOKEN}` && comment[0].authorId !== user.userId) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(taskComments).where(eq(taskComments.id, params.commentId));
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String(), commentId: t.String() }),
|
||||||
|
}
|
||||||
|
);
|
||||||
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 { useState, useEffect, useRef } from "react";
|
||||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
|
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";
|
import { useToast } from "./Toast";
|
||||||
|
|
||||||
const priorityColors: Record<TaskPriority, string> = {
|
const priorityColors: Record<TaskPriority, string> = {
|
||||||
@@ -241,6 +241,90 @@ interface TaskDetailPanelProps {
|
|||||||
token: string;
|
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) {
|
export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, hasToken, token }: TaskDetailPanelProps) {
|
||||||
const actions = statusActions[task.status] || [];
|
const actions = statusActions[task.status] || [];
|
||||||
const isActive = task.status === "active";
|
const isActive = task.status === "active";
|
||||||
@@ -986,6 +1070,9 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Comments */}
|
||||||
|
<CompactComments taskId={task.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save / Cancel Bar */}
|
{/* Save / Cancel Bar */}
|
||||||
|
|||||||
@@ -167,6 +167,42 @@ export async function addProgressNote(taskId: string, note: string): Promise<Tas
|
|||||||
return res.json();
|
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
|
// Admin API
|
||||||
export async function fetchUsers(): Promise<any[]> {
|
export async function fetchUsers(): Promise<any[]> {
|
||||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
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 type { Task, TaskStatus, Project } from "../lib/types";
|
||||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||||
import { useToast } from "../components/Toast";
|
import { useToast } from "../components/Toast";
|
||||||
|
import { TaskComments } from "../components/TaskComments";
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
const priorityColors: Record<string, string> = {
|
||||||
critical: "bg-red-500 text-white",
|
critical: "bg-red-500 text-white",
|
||||||
@@ -598,6 +599,8 @@ export function TaskPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Comments / Discussion */}
|
||||||
|
<TaskComments taskId={task.id} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|||||||
Reference in New Issue
Block a user