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 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 ───
|
||||
|
||||
export const users = pgTable("users", {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { taskRoutes } from "./routes/tasks";
|
||||
import { adminRoutes } from "./routes/admin";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { chatRoutes } from "./routes/chat";
|
||||
import { commentRoutes } from "./routes/comments";
|
||||
import { auth } from "./lib/auth";
|
||||
import { db } from "./db";
|
||||
import { tasks, users } from "./db/schema";
|
||||
@@ -115,6 +116,7 @@ const app = new Elysia()
|
||||
})
|
||||
|
||||
.use(taskRoutes)
|
||||
.use(commentRoutes)
|
||||
.use(projectRoutes)
|
||||
.use(adminRoutes)
|
||||
.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 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