From c3fb286bf4d2fa37132ed49cdb674c77baa3675e Mon Sep 17 00:00:00 2001 From: dev-minsoo Date: Tue, 9 Jun 2026 22:32:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=EC=99=80=20=EB=8C=93=EA=B8=80=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +-- docs/releases/v0.10.0.md | 2 +- .../documents/[slug]/edit/page.tsx | 3 +- src/app/(protected)/documents/new/page.tsx | 2 +- src/app/(protected)/help/page.tsx | 12 ++-- src/app/(protected)/page.tsx | 46 +++++++-------- src/components/document-comments.tsx | 10 ++-- src/components/document-detail-page.tsx | 2 +- src/components/document-editor.tsx | 2 +- src/lib/comment-utils.test.ts | 2 +- src/lib/content-routes.ts | 2 +- src/lib/documents.ts | 57 +++++++++++-------- src/types/devwiki.ts | 2 +- supabase/README.md | 4 +- 14 files changed, 83 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 1af2b67..c4567ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DevWiki -등록된 멤버가 기술 용어, 면접 Q&A, 상황 시뮬레이션을 함께 작성하고 토론하는 회원 전용 개발자 지식 베이스입니다. Next.js와 Supabase를 사용합니다. +등록된 멤버가 기술 용어, 면접 Q&A, 상황 시뮬레이션을 함께 작성하고 댓글로 의견을 남기는 회원 전용 개발자 지식 베이스입니다. Next.js와 Supabase를 사용합니다. ## Stack @@ -14,8 +14,8 @@ - Member-only access, role-based editing, profile nicknames - Per-member favorites - Database-backed full-text search with body snippets -- Expanded, threaded document discussions -- Recent discussion activity on the home dashboard +- Expanded, threaded document comments +- Recent comment activity on the home dashboard - Share metadata, generated app icons, and document link copying ## Local setup @@ -32,7 +32,7 @@ Supabase 연결 전에는 데모 문서가 보입니다. 실제 저장을 사용 - `기술 용어`: 기술 개념, 실무 예시, 꼬리 질문 - `면접 Q&A`: 기술/인성 질문과 답변 Tip -- `상황 시뮬레이션`: 서술형 상황 질문과 토론 +- `상황 시뮬레이션`: 서술형 상황 질문과 트레이드오프 Supabase가 연결된 환경에서는 로그인한 active member만 문서를 읽을 수 있습니다. `공개` 상태는 인터넷 공개가 아니라 전체 멤버 기본 목록에 노출된다는 뜻입니다. 홈은 통합 검색과 개인 즐겨찾기 현황을 보여주는 메인 화면이고, `/terms`, `/interviews`, `/scenarios`에서 섹션별 문서를 탐색합니다. diff --git a/docs/releases/v0.10.0.md b/docs/releases/v0.10.0.md index 0504103..3b52a6e 100644 --- a/docs/releases/v0.10.0.md +++ b/docs/releases/v0.10.0.md @@ -8,4 +8,4 @@ Release date: 2026-06-02 ### Features -* 최근 토론 활동 추가 ([39f23a5](https://github.com/geekgoing/devwiki/commit/39f23a55b4178443a987e43fd805e0ab1490489b)) +* 최근 댓글 활동 추가 ([39f23a5](https://github.com/geekgoing/devwiki/commit/39f23a55b4178443a987e43fd805e0ab1490489b)) diff --git a/src/app/(protected)/documents/[slug]/edit/page.tsx b/src/app/(protected)/documents/[slug]/edit/page.tsx index 21e8e1e..637daf1 100644 --- a/src/app/(protected)/documents/[slug]/edit/page.tsx +++ b/src/app/(protected)/documents/[slug]/edit/page.tsx @@ -64,7 +64,8 @@ export default async function EditDocumentPage({

editor 권한이 필요합니다

- viewer는 문서를 읽고 토론할 수 있지만 문서 수정은 할 수 없습니다. + viewer는 문서를 읽고 댓글을 남길 수 있지만 문서 수정은 할 수 + 없습니다.

diff --git a/src/app/(protected)/documents/new/page.tsx b/src/app/(protected)/documents/new/page.tsx index 56f1bb4..46ab91d 100644 --- a/src/app/(protected)/documents/new/page.tsx +++ b/src/app/(protected)/documents/new/page.tsx @@ -50,7 +50,7 @@ export default async function NewDocumentPage({

editor 권한이 필요합니다

- viewer는 문서를 읽고 토론할 수 있지만 새 문서 작성은 할 수 + viewer는 문서를 읽고 댓글을 남길 수 있지만 새 문서 작성은 할 수 없습니다.

diff --git a/src/app/(protected)/help/page.tsx b/src/app/(protected)/help/page.tsx index 3557376..8b307ad 100644 --- a/src/app/(protected)/help/page.tsx +++ b/src/app/(protected)/help/page.tsx @@ -21,14 +21,14 @@ const contentSections = [ }, { title: "상황 시뮬레이션", - body: "서술형 상황 질문을 문제 이해, 해결 전략, 트레이드오프 중심으로 토론합니다.", + body: "서술형 상황 질문을 문제 이해, 해결 전략, 트레이드오프 중심으로 정리합니다.", }, ]; const roleRows = [ - ["owner", "멤버 관리, 문서 작성/수정/복원, 이미지 업로드, 토론"], - ["editor", "문서 작성/수정/복원, 이미지 업로드, 토론"], - ["viewer", "문서 읽기와 토론 댓글 작성"], + ["owner", "멤버 관리, 문서 작성/수정/복원, 이미지 업로드, 댓글 작성"], + ["editor", "문서 작성/수정/복원, 이미지 업로드, 댓글 작성"], + ["viewer", "문서 읽기와 댓글 작성"], ]; export default async function HelpPage() { @@ -130,12 +130,12 @@ export default async function HelpPage() { - 토론 + 댓글

- 문서마다 토론 영역이 있습니다. 질문의 의도, 더 좋은 답변 흐름, + 문서마다 댓글 영역이 있습니다. 질문의 의도, 더 좋은 답변 흐름, 실제 면접에서 받은 꼬리 질문을 댓글로 남기고, 정리된 내용은 editor 이상이 문서 본문에 반영합니다.

diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index 0389292..1a7ef98 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -29,13 +29,13 @@ import { documentDetailPath, } from "@/lib/content-routes"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; -import { getDocuments, getRecentDiscussions } from "@/lib/documents"; +import { getDocuments, getRecentCommentActivities } from "@/lib/documents"; import { formatDate } from "@/lib/format"; import { isSupabaseConfigured } from "@/lib/supabase/env"; import type { DocumentContentType, DocumentSummary, - RecentDiscussion, + RecentCommentActivity, } from "@/types/devwiki"; const sectionCards = [ @@ -134,12 +134,12 @@ function SmallDocumentLink({ document }: { document: DocumentSummary }) { ); } -function DiscussionDocumentLink({ - discussion, +function RecentCommentActivityLink({ + activity, }: { - discussion: RecentDiscussion; + activity: RecentCommentActivity; }) { - const href = `${documentDetailPath(discussion.document)}#discussion`; + const href = `${documentDetailPath(activity.document)}#comments`; return ( - {discussion.document.title} + {activity.document.title} - {discussion.latestCommentBody} + {activity.latestCommentBody} - {contentTypeLabels[discussion.document.contentType]} + {contentTypeLabels[activity.document.contentType]} - {discussion.totalCommentCount}개 + {activity.totalCommentCount}개 - {discussion.replyCount ? ( - 답글 {discussion.replyCount}개 + {activity.replyCount ? ( + 답글 {activity.replyCount}개 ) : null} - {discussion.latestCommentAuthorLabel} ·{" "} - {formatDate(discussion.latestCommentAt)} + {activity.latestCommentAuthorLabel} ·{" "} + {formatDate(activity.latestCommentAt)} ); @@ -177,13 +177,13 @@ export default async function Home() { const member = await getCurrentMember(); const canReadPrivate = !configured || Boolean(member); - const [allDocuments, recentDiscussions] = await Promise.all([ + const [allDocuments, recentCommentActivities] = await Promise.all([ getDocuments({ status: "active", canReadPrivate, viewerId: user?.id, }), - getRecentDiscussions({ + getRecentCommentActivities({ canReadPrivate, viewerId: user?.id, }), @@ -361,20 +361,20 @@ export default async function Home() { className="text-primary" aria-hidden /> - 최근 토론 + 최근 댓글 - {recentDiscussions.length ? ( - recentDiscussions.map((discussion) => ( - ( + )) ) : (

- 아직 최근 토론이 없습니다. + 아직 최근 댓글이 없습니다.

)}
diff --git a/src/components/document-comments.tsx b/src/components/document-comments.tsx index f59946e..6c12b51 100644 --- a/src/components/document-comments.tsx +++ b/src/components/document-comments.tsx @@ -115,7 +115,7 @@ export function DocumentComments({ const [replyingToCommentId, setReplyingToCommentId] = useState( null, ); - const canDiscuss = Boolean(configured && currentUserId && memberRole); + const canComment = Boolean(configured && currentUserId && memberRole); const canModerate = canEditContent(memberRole ? { role: memberRole } : null); const commentStats = getDocumentCommentStats(comments); @@ -133,7 +133,7 @@ export function DocumentComments({ const isEditing = editingCommentId === comment.id; const isReplying = replyingToCommentId === comment.id; const canManage = canModerate || comment.createdBy === currentUserId; - const canReply = canDiscuss && !isReply && !isEditing; + const canReply = canComment && !isReply && !isEditing; const replyCount = comment.replies.length; return ( @@ -343,7 +343,7 @@ export function DocumentComments({ } return ( - +
@@ -352,7 +352,7 @@ export function DocumentComments({ className="text-muted-foreground" aria-hidden /> - 토론 + 댓글 댓글 {commentStats.topLevelCount}개 @@ -366,7 +366,7 @@ export function DocumentComments({ - {canDiscuss ? ( + {canComment ? (
- 토론 + 댓글 {commentStats.totalCount}개 diff --git a/src/components/document-editor.tsx b/src/components/document-editor.tsx index 353db1a..2df12de 100644 --- a/src/components/document-editor.tsx +++ b/src/components/document-editor.tsx @@ -167,7 +167,7 @@ const scenarioStarterMarkdown = `# 상황 ## 답변 예시 -## 토론 포인트 +## 댓글로 확인할 점 `; const starterMarkdownByType: Record = { diff --git a/src/lib/comment-utils.test.ts b/src/lib/comment-utils.test.ts index 023ace7..d942a71 100644 --- a/src/lib/comment-utils.test.ts +++ b/src/lib/comment-utils.test.ts @@ -40,7 +40,7 @@ describe("comment utils", () => { expect(countDocumentComments(comments)).toBe(4); }); - it("counts flat comment rows for discussion summaries", () => { + it("counts flat comment rows for comment activity summaries", () => { expect( getFlatDocumentCommentStats([ { parentCommentId: null }, diff --git a/src/lib/content-routes.ts b/src/lib/content-routes.ts index 9112997..576da53 100644 --- a/src/lib/content-routes.ts +++ b/src/lib/content-routes.ts @@ -13,7 +13,7 @@ export const contentTypeLabels: Record = { export const contentTypeSummaries: Record = { term: "기술 개념, 실무 예시, 꼬리 질문을 빠르게 훑습니다.", interview_qa: "면접에서 받은 질문과 답변 Tip을 Q&A 형태로 정리합니다.", - scenario: "서술형 상황 질문을 해결 흐름과 토론 중심으로 다룹니다.", + scenario: "서술형 상황 질문을 해결 흐름과 트레이드오프 중심으로 다룹니다.", }; export const contentRoutes = { diff --git a/src/lib/documents.ts b/src/lib/documents.ts index a94bf6e..5bb8bee 100644 --- a/src/lib/documents.ts +++ b/src/lib/documents.ts @@ -15,7 +15,7 @@ import type { DocumentStatus, DocumentStatusFilter, DocumentSummary, - RecentDiscussion, + RecentCommentActivity, RelatedDocument, Tag, } from "@/types/devwiki"; @@ -66,7 +66,7 @@ type RawComment = { updated_by: string | null; }; -type RawRecentDiscussionComment = { +type RawRecentCommentActivityComment = { id: string; body: string; created_at: string; @@ -82,7 +82,7 @@ const DOCUMENT_LIST_LIMIT = 100; const DOCUMENT_SEARCH_PAGE_SIZE = 500; const DOCUMENT_SEARCH_MAX_ROWS = 5000; const DOCUMENT_SEARCH_LIMIT = 100; -const RECENT_DISCUSSION_SCAN_LIMIT = 80; +const RECENT_COMMENT_ACTIVITY_SCAN_LIMIT = 80; const DEFAULT_MEMBER_STATUSES: DocumentStatus[] = ["published", "draft"]; const DOCUMENT_DETAIL_SELECT = "id, slug, title, summary, body_markdown, status, content_type, interview_category, created_at, updated_at, created_by, updated_by, document_tags(tags(id, name, slug))"; @@ -726,7 +726,7 @@ export async function getDocumentComments( return topLevelComments; } -async function getDiscussionCounts( +async function getCommentActivityCounts( supabase: SupabaseReader, documentIds: string[], ): Promise< @@ -787,34 +787,45 @@ async function getDiscussionCounts( ); } -export async function getRecentDiscussions({ +async function selectRecentCommentActivityCommentRows( + supabase: SupabaseReader, + scanLimit: number, +): Promise { + const select = + "id, body, created_at, created_by, document_id, parent_comment_id, updated_at"; + + const { data, error } = await supabase + .from("comments") + .select(select) + .order("updated_at", { ascending: false }) + .limit(scanLimit); + + if (error) { + throw new Error(error.message); + } + + return (data ?? []) as RawRecentCommentActivityComment[]; +} + +export async function getRecentCommentActivities({ canReadPrivate = false, limit = 4, viewerId = null, }: DocumentReadOptions & { limit?: number; -} = {}): Promise { +} = {}): Promise { if (!isSupabaseConfigured() || !canReadPrivate) { return []; } const supabase = await createClient(); - const { data, error } = await supabase - .from("comments") - .select( - "id, body, created_at, created_by, document_id, parent_comment_id, updated_at", - ) - .order("updated_at", { ascending: false }) - .limit(RECENT_DISCUSSION_SCAN_LIMIT); - - if (error) { - throw new Error(error.message); - } - - const rows = (data ?? []) as RawRecentDiscussionComment[]; + const rows = await selectRecentCommentActivityCommentRows( + supabase, + RECENT_COMMENT_ACTIVITY_SCAN_LIMIT, + ); const latestCommentByDocumentId = new Map< string, - RawRecentDiscussionComment + RawRecentCommentActivityComment >(); rows.forEach((row) => { @@ -843,7 +854,7 @@ export async function getRecentDiscussions({ const visibleDocumentIds = documentIds .filter((documentId) => visibleDocumentById.has(documentId)) .slice(0, Math.max(limit, 0)); - const [authorLabels, discussionCounts] = await Promise.all([ + const [authorLabels, commentActivityCounts] = await Promise.all([ getCommentAuthorLabels( visibleDocumentIds .map( @@ -852,7 +863,7 @@ export async function getRecentDiscussions({ ) .filter((id): id is string => Boolean(id)), ), - getDiscussionCounts(supabase, visibleDocumentIds), + getCommentActivityCounts(supabase, visibleDocumentIds), ]); return visibleDocumentIds.flatMap((documentId) => { @@ -863,7 +874,7 @@ export async function getRecentDiscussions({ return []; } - const counts = discussionCounts.get(documentId) ?? { + const counts = commentActivityCounts.get(documentId) ?? { replyCount: 0, totalCommentCount: 0, }; diff --git a/src/types/devwiki.ts b/src/types/devwiki.ts index 2db30bb..db8d518 100644 --- a/src/types/devwiki.ts +++ b/src/types/devwiki.ts @@ -58,7 +58,7 @@ export type DocumentDetail = DocumentSummary & { export type RelatedDocument = DocumentSummary; -export type RecentDiscussion = { +export type RecentCommentActivity = { document: DocumentSummary; latestCommentAt: string; latestCommentAuthorLabel: string; diff --git a/supabase/README.md b/supabase/README.md index ea9745f..c971ea5 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -57,7 +57,7 @@ Supabase Data API에서 public 테이블에 접근할 수 있도록 명시적으 `document_member_states` 테이블과 RLS 정책을 추가합니다. 여덟 번째 migration은 문서 본문 검색용 `search_vector`와 `search_documents` RPC, 댓글 수정 추적과 editor/author 댓글 관리 정책을 -추가합니다. 아홉 번째 migration은 토론을 일반 대화 공간으로 유지하기 위해 +추가합니다. 아홉 번째 migration은 댓글을 일반 대화 공간으로 유지하기 위해 댓글 해결 상태 컬럼을 제거합니다. 열 번째 migration은 댓글에 1단계 대댓글 관계를 추가하고, 대댓글이 같은 문서의 원댓글에만 달리도록 검증합니다. 열한 번째 migration은 숙지 완료 상태 컬럼과 관련 인덱스를 제거하고, @@ -84,7 +84,7 @@ set display_name = excluded.display_name, 앱은 `members`에 `is_active = true`로 등록된 이메일만 문서를 읽을 수 있게 합니다. `owner`와 `editor`는 문서 작성, 수정, 복원, 이미지 업로드가 가능하고 -`viewer`는 읽기와 토론 댓글만 가능합니다. 멤버십 조회는 로그인 이메일과 +`viewer`는 읽기와 댓글 작성만 가능합니다. 멤버십 조회는 로그인 이메일과 `members.email`을 직접 비교하므로, 이메일은 소문자로 등록하세요. 첫 owner 계정을 수동 등록한 뒤 로그인하면 `/admin/members`에서 이후 회원가입 사용자를 승인할 수 있습니다. 사용자는 `/signup`에서 이메일과 비밀번호로 회원가입하고,