diff --git a/README.md b/README.md index 548bc09..c5060be 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ Supabase가 연결된 환경에서는 로그인한 active member만 문서를 Owner/editor members can upload `png`, `jpeg`, `webp`, and `gif` images from the document editor. Uploaded files are stored in the private `devwiki-assets` Supabase Storage bucket and inserted into the document as Markdown image syntax. +## Release notes + +- [v0.5.0](docs/releases/v0.5.0.md): threaded discussions, learning-route cleanup, search and navigation polish + ## Member management Users can sign up from `/signup`. Signup creates a confirmed Supabase Auth user on the server and an inactive `members` row with an automatically generated nickname, so no signup confirmation email is sent. Owner members can open `/admin/members`, choose a role for pending users, and activate them. Members can update their nickname and password from `/me`. The first owner account still has to be created in Supabase once before the in-app admin screen can be used. diff --git a/docs/releases/v0.5.0.md b/docs/releases/v0.5.0.md new file mode 100644 index 0000000..8638a86 --- /dev/null +++ b/docs/releases/v0.5.0.md @@ -0,0 +1,40 @@ +# DevWiki v0.5.0 Release Notes + +Release date: 2026-06-02 + +## Highlights + +- 문서 토론을 해결 체크리스트가 아니라 일반 대화 공간으로 정리했습니다. +- 댓글에 1단계 대댓글을 추가해 문서별 논의를 맥락별로 이어갈 수 있습니다. +- 추천 학습 루트와 숙지 완료 상태를 제거하고, 개인 문서 상태는 즐겨찾기 중심으로 단순화했습니다. +- 검색 결과와 탐색 화면의 필터 흐름, 태그 검색 링크, 즐겨찾기 카운트를 정리했습니다. + +## User-Facing Changes + +- 문서 상세의 토론 영역이 본문 아래로 이동해 긴 대화를 더 넓게 읽을 수 있습니다. +- 토론 카운트는 댓글과 답글을 함께 반영합니다. +- 태그를 누르면 홈이 아니라 통합 검색 화면으로 이동합니다. +- 검색 첫 화면에서 추천 검색어를 바로 실행할 수 있습니다. +- 홈의 즐겨찾기 요약은 미리보기 개수가 아니라 전체 즐겨찾기 수를 보여줍니다. + +## Database Changes + +Apply the new migrations in filename order: + +1. `20260602104500_remove_comment_resolution.sql` +2. `20260602110000_add_comment_replies.sql` +3. `20260602134005_remove_completed_document_state.sql` + +These migrations remove comment resolution fields, add first-level comment replies, and drop the completed-document member state. + +## Verification + +Recommended release checks: + +```bash +npm run lint +npm run test +npm run build +``` + +Run `npm run verify:mvp` when Supabase service-role and E2E credentials are available. diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index a307e23..4f55e70 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -138,9 +138,11 @@ export default async function Home() { canReadPrivate, viewerId: user?.id, }); - const favoriteDocuments = allDocuments - .filter((document) => document.isFavorite) - .slice(0, 4); + const allFavoriteDocuments = allDocuments.filter( + (document) => document.isFavorite, + ); + const favoriteDocuments = allFavoriteDocuments.slice(0, 4); + const favoriteDocumentCount = allFavoriteDocuments.length; const recentDocuments = allDocuments.slice(0, 6); const counts = getDocumentCounts(allDocuments); const documentsByContentType = getDocumentsByContentType(allDocuments); @@ -199,7 +201,7 @@ export default async function Home() { 저장한 문서 - {favoriteDocuments.length} + {favoriteDocumentCount} diff --git a/src/components/document-collection-client.tsx b/src/components/document-collection-client.tsx index 60e4106..bbb5b15 100644 --- a/src/components/document-collection-client.tsx +++ b/src/components/document-collection-client.tsx @@ -3,7 +3,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useMemo } from "react"; import { DocumentDiscoveryBoard } from "@/components/document-discovery-board"; @@ -86,6 +86,7 @@ export function DocumentCollectionClient({ initialFilters: DocumentQueryFilters; routePath: string; }) { + const router = useRouter(); const searchParams = useSearchParams(); const currentFilters = readDocumentQueryFilters(searchParams, contentType); const initialData = isInitialFilters(currentFilters, initialFilters) @@ -119,6 +120,7 @@ export function DocumentCollectionClient({ currentFilters.interviewCategory, ); const showSkeleton = documentsQuery.isPending && !documents.length; + const navigate = (href: string) => router.push(href, { scroll: false }); return (
@@ -142,7 +144,7 @@ export function DocumentCollectionClient({ } contentType={contentType} favoritesOnly={currentFilters.favoritesOnly} - onNavigate={(href) => window.history.pushState(null, "", href)} + onNavigate={navigate} status={currentFilters.status} /> {canCreate ? ( diff --git a/src/components/document-comments.tsx b/src/components/document-comments.tsx index b54b4bd..3becb82 100644 --- a/src/components/document-comments.tsx +++ b/src/components/document-comments.tsx @@ -1,7 +1,8 @@ "use client"; import { MessageSquare, Pencil, Reply, Trash2, X } from "lucide-react"; -import { useState } from "react"; +import { useRef, useState, type ComponentProps, type ReactNode } from "react"; +import { useFormStatus } from "react-dom"; import { addComment as addCommentAction, @@ -13,12 +14,13 @@ import { Card, CardAction, CardContent, + CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { countDocumentComments } from "@/lib/comment-utils"; +import { getDocumentCommentStats } from "@/lib/comment-utils"; import { canEditContent } from "@/lib/permissions"; import { formatDate } from "@/lib/format"; import type { @@ -82,6 +84,23 @@ function authorInitial(label: string) { return label.trim().slice(0, 1).toUpperCase() || "?"; } +function SubmitButton({ + children, + pendingLabel, + ...props +}: ComponentProps & { + children: ReactNode; + pendingLabel: string; +}) { + const { pending } = useFormStatus(); + + return ( + + ); +} + export function DocumentComments({ comments, configured, @@ -91,13 +110,19 @@ export function DocumentComments({ memberRole, slug, }: DocumentCommentsProps) { + const commentFormRef = useRef(null); 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); + const commentStats = getDocumentCommentStats(comments); + + async function submitComment(formData: FormData) { + await addCommentAction(formData); + commentFormRef.current?.reset(); + } async function submitReply(formData: FormData) { await addCommentAction(formData); @@ -109,6 +134,7 @@ export function DocumentComments({ const isReplying = replyingToCommentId === comment.id; const canManage = canModerate || comment.createdBy === currentUserId; const canReply = canDiscuss && !isReply && !isEditing; + const replyCount = comment.replies.length; return (
  • · + {!isReply && replyCount ? ( + <> + · + 답글 {replyCount}개 + + ) : null} {comment.updatedAt !== comment.createdAt ? ( <> · @@ -142,7 +174,13 @@ export function DocumentComments({
  • {isEditing ? ( -
    + { + await updateComment(formData); + setEditingCommentId(null); + }} + className="mt-3 space-y-3" + >
    - + + ) : null}
    @@ -269,9 +320,13 @@ export function DocumentComments({ 취소 - + ) : null} @@ -290,22 +345,31 @@ export function DocumentComments({ return ( - - - 토론 - +
    + + + 토론 + + + 댓글 {commentStats.topLevelCount}개 + {commentStats.replyCount + ? ` · 답글 ${commentStats.replyCount}개` + : ""} + +
    - {commentCount}개 + {commentStats.totalCount}개
    {canDiscuss ? (
    - + + 의견 남기기 +
    ) : configured ? ( diff --git a/src/components/document-detail-page.tsx b/src/components/document-detail-page.tsx index 6ae2580..3101d6d 100644 --- a/src/components/document-detail-page.tsx +++ b/src/components/document-detail-page.tsx @@ -25,13 +25,14 @@ import { StatusBadge } from "@/components/status-badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { countDocumentComments } from "@/lib/comment-utils"; +import { getDocumentCommentStats } from "@/lib/comment-utils"; import { contentTypeLabels, contentTypePath, documentDetailPath, documentEditPath, legacyDocumentPath, + withSearchParams, } from "@/lib/content-routes"; import { formatDate } from "@/lib/format"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; @@ -267,7 +268,7 @@ export async function DocumentDetailPage({ const canSaveFavorite = Boolean(configured && member); const shouldShowRelatedDocuments = relatedDocuments.length > 0 || canContribute; - const commentCount = countDocumentComments(comments); + const commentStats = getDocumentCommentStats(comments); const editHref = documentEditPath(document.slug); const listHref = contentTypePath(document.contentType); @@ -355,8 +356,13 @@ export async function DocumentDetailPage({ 토론 - {commentCount}개 + {commentStats.totalCount}개 + {commentStats.replyCount ? ( + + 답글 {commentStats.replyCount}개 포함 + + ) : null} @@ -364,7 +370,7 @@ export async function DocumentDetailPage({
    {document.tags.map((tag) => ( - + {tag.name} diff --git a/src/components/document-search-client.tsx b/src/components/document-search-client.tsx index fcfddd8..0f69d59 100644 --- a/src/components/document-search-client.tsx +++ b/src/components/document-search-client.tsx @@ -2,7 +2,8 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; -import { useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, type FormEvent } from "react"; import { DocumentFilterToolbar } from "@/components/document-filter-toolbar"; @@ -28,6 +29,8 @@ import { } from "@/lib/document-query"; import type { DocumentSummary } from "@/types/devwiki"; +const searchSuggestions = ["트랜잭션", "인덱스", "MSA", "피드백"]; + async function fetchDocuments(filters: DocumentQueryFilters) { const response = await fetch(documentApiPath(filters), { credentials: "same-origin", @@ -120,6 +123,7 @@ export function DocumentSearchClient({ initialDocuments: DocumentSummary[]; initialFilters: DocumentQueryFilters; }) { + const router = useRouter(); const searchParams = useSearchParams(); const currentFilters = readDocumentQueryFilters(searchParams); const hasAnyFilter = hasActiveDocumentQuery(currentFilters); @@ -141,6 +145,7 @@ export function DocumentSearchClient({ const title = currentFilters.query ? `"${currentFilters.query}" 검색 결과` : "문서 검색"; + const navigate = (href: string) => router.push(href, { scroll: false }); return (
    @@ -158,7 +163,7 @@ export function DocumentSearchClient({ basePath="/search" category={currentFilters.interviewCategory} favoritesOnly={currentFilters.favoritesOnly} - onNavigate={(href) => window.history.pushState(null, "", href)} + onNavigate={navigate} query={currentFilters.query} status={currentFilters.status} /> @@ -169,9 +174,7 @@ export function DocumentSearchClient({ key={currentFilters.query} query={currentFilters.query} onSubmit={(query) => - window.history.pushState( - null, - "", + navigate( searchHref({ ...currentFilters, query, @@ -191,6 +194,15 @@ export function DocumentSearchClient({

    예: MSA, 트랜잭션, 브라우저 URL 입력, 피드백

    +
    + {searchSuggestions.map((query) => ( + + ))} +
    ) : documentsQuery.isError ? (
    { + it("counts top-level comments and replies separately", () => { + const comments = [ + comment("comment-1", [comment("reply-1"), comment("reply-2")]), + comment("comment-2"), + ]; + + expect(getDocumentCommentStats(comments)).toEqual({ + replyCount: 2, + topLevelCount: 2, + totalCount: 4, + }); + expect(countDocumentComments(comments)).toBe(4); + }); +}); diff --git a/src/lib/comment-utils.ts b/src/lib/comment-utils.ts index 10a3695..54081be 100644 --- a/src/lib/comment-utils.ts +++ b/src/lib/comment-utils.ts @@ -1,8 +1,35 @@ import type { DocumentComment } from "@/types/devwiki"; -export function countDocumentComments(comments: DocumentComment[]): number { - return comments.reduce( - (total, comment) => total + 1 + countDocumentComments(comment.replies), - 0, +export type DocumentCommentStats = { + replyCount: number; + topLevelCount: number; + totalCount: number; +}; + +export function getDocumentCommentStats( + comments: DocumentComment[], + depth = 0, +): DocumentCommentStats { + return comments.reduce( + (stats, comment) => { + const childStats = getDocumentCommentStats(comment.replies, depth + 1); + + return { + replyCount: + stats.replyCount + childStats.replyCount + (depth > 0 ? 1 : 0), + topLevelCount: + stats.topLevelCount + childStats.topLevelCount + (depth === 0 ? 1 : 0), + totalCount: stats.totalCount + childStats.totalCount + 1, + }; + }, + { + replyCount: 0, + topLevelCount: 0, + totalCount: 0, + }, ); } + +export function countDocumentComments(comments: DocumentComment[]): number { + return getDocumentCommentStats(comments).totalCount; +}