Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions docs/releases/v0.5.0.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 6 additions & 4 deletions src/app/(protected)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -199,7 +201,7 @@ export default async function Home() {
저장한 문서
</span>
<span className="mt-2 block text-2xl font-semibold">
{favoriteDocuments.length}
{favoriteDocumentCount}
</span>
</Link>
</CardContent>
Expand Down
6 changes: 4 additions & 2 deletions src/components/document-collection-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<div className="grid gap-5">
Expand All @@ -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 ? (
Expand Down
110 changes: 88 additions & 22 deletions src/components/document-comments.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -82,6 +84,23 @@ function authorInitial(label: string) {
return label.trim().slice(0, 1).toUpperCase() || "?";
}

function SubmitButton({
children,
pendingLabel,
...props
}: ComponentProps<typeof Button> & {
children: ReactNode;
pendingLabel: string;
}) {
const { pending } = useFormStatus();

return (
<Button {...props} disabled={pending || props.disabled}>
{pending ? pendingLabel : children}
</Button>
);
}

export function DocumentComments({
comments,
configured,
Expand All @@ -91,13 +110,19 @@ export function DocumentComments({
memberRole,
slug,
}: DocumentCommentsProps) {
const commentFormRef = useRef<HTMLFormElement>(null);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
const [replyingToCommentId, setReplyingToCommentId] = useState<string | null>(
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);
Expand All @@ -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 (
<li
Expand All @@ -130,6 +156,12 @@ export function DocumentComments({
</span>
<span aria-hidden>·</span>
<time>{formatDate(comment.createdAt)}</time>
{!isReply && replyCount ? (
<>
<span aria-hidden>·</span>
<Badge variant="secondary">답글 {replyCount}개</Badge>
</>
) : null}
{comment.updatedAt !== comment.createdAt ? (
<>
<span aria-hidden>·</span>
Expand All @@ -142,7 +174,13 @@ export function DocumentComments({
</div>

{isEditing ? (
<form action={updateComment} className="mt-3 space-y-3">
<form
action={async (formData) => {
await updateComment(formData);
setEditingCommentId(null);
}}
className="mt-3 space-y-3"
>
<CommentHiddenFields
commentId={comment.id}
contentType={contentType}
Expand All @@ -158,9 +196,9 @@ export function DocumentComments({
className="min-h-28 resize-y bg-background text-sm leading-6"
/>
<div className="flex flex-wrap gap-2">
<Button type="submit" size="sm">
<SubmitButton type="submit" size="sm" pendingLabel="저장 중">
저장
</Button>
</SubmitButton>
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -210,7 +248,15 @@ export function DocumentComments({
) : null}
{canManage ? (
<form
action={deleteComment}
action={async (formData) => {
await deleteComment(formData);
setEditingCommentId((current) =>
current === comment.id ? null : current,
);
setReplyingToCommentId((current) =>
current === comment.id ? null : current,
);
}}
onSubmit={(event) => {
const message = comment.replies.length
? "댓글을 삭제할까요? 대댓글도 함께 삭제됩니다."
Expand All @@ -227,10 +273,15 @@ export function DocumentComments({
documentId={documentId}
slug={slug}
/>
<Button type="submit" variant="outline" size="sm">
<SubmitButton
type="submit"
variant="outline"
size="sm"
pendingLabel="삭제 중"
>
<Trash2 size={14} aria-hidden />
삭제
</Button>
</SubmitButton>
</form>
) : null}
</div>
Expand Down Expand Up @@ -269,9 +320,13 @@ export function DocumentComments({
<X size={14} aria-hidden />
취소
</Button>
<Button type="submit" size="sm">
<SubmitButton
type="submit"
size="sm"
pendingLabel="등록 중"
>
답글 남기기
</Button>
</SubmitButton>
</div>
</form>
) : null}
Expand All @@ -290,22 +345,31 @@ export function DocumentComments({
return (
<Card>
<CardHeader className="border-b pb-4 sm:px-6">
<CardTitle className="flex items-center gap-2">
<MessageSquare
size={18}
className="text-muted-foreground"
aria-hidden
/>
토론
</CardTitle>
<div>
<CardTitle className="flex items-center gap-2">
<MessageSquare
size={18}
className="text-muted-foreground"
aria-hidden
/>
토론
</CardTitle>
<CardDescription className="mt-1">
댓글 {commentStats.topLevelCount}개
{commentStats.replyCount
? ` · 답글 ${commentStats.replyCount}개`
: ""}
</CardDescription>
</div>
<CardAction>
<Badge variant="secondary">{commentCount}개</Badge>
<Badge variant="secondary">{commentStats.totalCount}개</Badge>
</CardAction>
</CardHeader>
<CardContent className="space-y-5 sm:px-6">
{canDiscuss ? (
<form
action={addCommentAction}
ref={commentFormRef}
action={submitComment}
className="rounded-lg border bg-muted/35 p-4 sm:p-5"
>
<DocumentHiddenFields
Expand All @@ -322,7 +386,9 @@ export function DocumentComments({
placeholder="질문, 추가 사례, 다른 관점을 남겨보세요."
/>
<div className="mt-3 flex justify-end">
<Button type="submit">의견 남기기</Button>
<SubmitButton type="submit" pendingLabel="등록 중">
의견 남기기
</SubmitButton>
</div>
</form>
) : configured ? (
Expand Down
14 changes: 10 additions & 4 deletions src/components/document-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -355,16 +356,21 @@ export async function DocumentDetailPage({
토론
</span>
<strong className="mt-1 block font-medium text-foreground">
{commentCount}개
{commentStats.totalCount}개
</strong>
{commentStats.replyCount ? (
<span className="mt-0.5 block text-[11px]">
답글 {commentStats.replyCount}개 포함
</span>
) : null}
</div>
</div>

{document.tags.length ? (
<div className="mt-4 flex flex-wrap gap-2">
{document.tags.map((tag) => (
<Badge key={tag.id} asChild variant="secondary">
<Link href={`/?q=${encodeURIComponent(tag.name)}`}>
<Link href={withSearchParams("/search", { q: tag.name })}>
<Tags size={12} aria-hidden />
{tag.name}
</Link>
Expand Down
Loading
Loading