diff --git a/README.md b/README.md index fd25e6f..5fea0d8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - Member-only access, role-based editing, profile nicknames - Per-member favorites and completed-learning filters - Database-backed full-text search with body snippets -- Editable, resolvable document discussions +- Expanded, threaded document discussions - Share metadata, generated app icons, and document link copying ## Local setup diff --git a/src/app/actions.ts b/src/app/actions.ts index 020103e..7c6e323 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -113,6 +113,7 @@ const documentLearningStateSchema = z.object({ const commentSchema = z.object({ contentType: z.enum(["term", "interview_qa", "scenario"]), documentId: z.string().uuid(), + parentCommentId: z.string().uuid().optional(), slug: z.string().trim().min(1), }); @@ -125,11 +126,6 @@ const deleteCommentSchema = commentSchema.extend({ commentId: z.string().uuid(), }); -const resolveCommentSchema = commentSchema.extend({ - commentId: z.string().uuid(), - resolved: z.boolean(), -}); - function readString(formData: FormData, key: string) { const value = formData.get(key); return typeof value === "string" ? value : ""; @@ -810,7 +806,7 @@ async function getCommentForAction( ) { const { data, error } = await supabase .from("comments") - .select("id, created_by") + .select("id, created_by, parent_comment_id") .eq("id", commentId) .eq("document_id", documentId) .single(); @@ -827,6 +823,7 @@ export async function addComment(formData: FormData) { const parsed = commentSchema.parse({ contentType: readString(formData, "content_type") || "term", documentId: readString(formData, "document_id"), + parentCommentId: readString(formData, "parent_comment_id") || undefined, slug: readString(formData, "slug"), }); const body = readString(formData, "body").trim(); @@ -835,10 +832,23 @@ export async function addComment(formData: FormData) { return; } + if (parsed.parentCommentId) { + const parentComment = await getCommentForAction( + supabase, + parsed.parentCommentId, + parsed.documentId, + ); + + if (parentComment.parent_comment_id) { + throw new Error("대댓글에는 답글을 달 수 없습니다."); + } + } + const { error } = await supabase.from("comments").insert({ document_id: parsed.documentId, body, created_by: user.id, + parent_comment_id: parsed.parentCommentId ?? null, updated_by: user.id, }); @@ -913,34 +923,6 @@ export async function deleteComment(formData: FormData) { revalidateDocumentPaths(parsed.slug, parsed.contentType); } -export async function resolveComment(formData: FormData) { - const { supabase, user } = await requireEditorMember(); - const parsed = resolveCommentSchema.parse({ - commentId: readString(formData, "comment_id"), - contentType: readString(formData, "content_type") || "term", - documentId: readString(formData, "document_id"), - resolved: readString(formData, "resolved") === "1", - slug: readString(formData, "slug"), - }); - - await getCommentForAction(supabase, parsed.commentId, parsed.documentId); - - const { error } = await supabase - .from("comments") - .update({ - resolved_at: parsed.resolved ? new Date().toISOString() : null, - resolved_by: parsed.resolved ? user.id : null, - updated_by: user.id, - }) - .eq("id", parsed.commentId); - - if (error) { - throw new Error(error.message); - } - - revalidateDocumentPaths(parsed.slug, parsed.contentType); -} - export async function updateMyProfile(formData: FormData) { const { supabase, user } = await requireAuthenticatedMember(); const randomize = readString(formData, "randomize") === "1"; diff --git a/src/components/document-comments.tsx b/src/components/document-comments.tsx index e51bbe1..b54b4bd 100644 --- a/src/components/document-comments.tsx +++ b/src/components/document-comments.tsx @@ -1,12 +1,11 @@ "use client"; -import { CheckCircle2, MessageSquare, Pencil, Trash2, X } from "lucide-react"; +import { MessageSquare, Pencil, Reply, Trash2, X } from "lucide-react"; import { useState } from "react"; import { addComment as addCommentAction, deleteComment, - resolveComment, updateComment, } from "@/app/actions"; import { Badge } from "@/components/ui/badge"; @@ -19,6 +18,7 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; +import { countDocumentComments } from "@/lib/comment-utils"; import { canEditContent } from "@/lib/permissions"; import { formatDate } from "@/lib/format"; import type { @@ -37,13 +37,11 @@ type DocumentCommentsProps = { slug: string; }; -function CommentHiddenFields({ - commentId, +function DocumentHiddenFields({ contentType, documentId, slug, }: { - commentId: string; contentType: DocumentContentType; documentId: string; slug: string; @@ -53,11 +51,37 @@ function CommentHiddenFields({ + + ); +} + +function CommentHiddenFields({ + commentId, + contentType, + documentId, + slug, +}: { + commentId: string; + contentType: DocumentContentType; + documentId: string; + slug: string; +}) { + return ( + <> + ); } +function authorInitial(label: string) { + return label.trim().slice(0, 1).toUpperCase() || "?"; +} + export function DocumentComments({ comments, configured, @@ -68,200 +92,254 @@ export function DocumentComments({ slug, }: DocumentCommentsProps) { const [editingCommentId, setEditingCommentId] = useState(null); + const [replyingToCommentId, setReplyingToCommentId] = useState( + null, + ); const canDiscuss = Boolean(configured && currentUserId && memberRole); const canModerate = canEditContent(memberRole ? { role: memberRole } : null); + const commentCount = countDocumentComments(comments); + + async function submitReply(formData: FormData) { + await addCommentAction(formData); + setReplyingToCommentId(null); + } + + function renderComment(comment: DocumentComment, isReply = false) { + const isEditing = editingCommentId === comment.id; + const isReplying = replyingToCommentId === comment.id; + const canManage = canModerate || comment.createdBy === currentUserId; + const canReply = canDiscuss && !isReply && !isEditing; + + return ( +
  • +
    +
    + {authorInitial(comment.authorLabel)} +
    +
    +
    + + {comment.authorLabel} + + · + + {comment.updatedAt !== comment.createdAt ? ( + <> + · + + 수정됨 + {comment.editorLabel ? `: ${comment.editorLabel}` : ""} + + + ) : null} +
    + + {isEditing ? ( +
    + +