diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5abc843..be9221e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,6 +80,33 @@ jobs: echo "new_release_published=false" >> "$GITHUB_OUTPUT" fi + - name: Sync release notes docs + if: steps.semantic.outputs.new_release_published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.semantic.outputs.new_release_version }} + run: | + RELEASE_TAG="v${RELEASE_VERSION}" + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + + gh release view "${RELEASE_TAG}" --json body --jq .body > "${NOTES_FILE}" + node scripts/sync-release-docs.mjs \ + --version "${RELEASE_VERSION}" \ + --date "$(date -u +%F)" \ + --notes-file "${NOTES_FILE}" + + if git diff --quiet -- README.md docs/releases; then + echo "release notes docs are already synced for ${RELEASE_TAG}" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add README.md docs/releases + git commit -m "docs: 릴리즈 노트 ${RELEASE_TAG} 동기화" + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:main + build-and-push: needs: release runs-on: ubuntu-latest diff --git a/README.md b/README.md index c5060be..2d44b34 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - Per-member favorites - Database-backed full-text search with body snippets - Expanded, threaded document discussions +- Recent discussion activity on the home dashboard - Share metadata, generated app icons, and document link copying ## Local setup @@ -43,7 +44,7 @@ Owner/editor members can upload `png`, `jpeg`, `webp`, and `gif` images from the ## Release notes -- [v0.5.0](docs/releases/v0.5.0.md): threaded discussions, learning-route cleanup, search and navigation polish +- [v0.9.0](docs/releases/v0.9.0.md): latest release notes ## Member management diff --git a/docs/releases/v0.5.0.md b/docs/releases/v0.5.0.md deleted file mode 100644 index 8638a86..0000000 --- a/docs/releases/v0.5.0.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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/docs/releases/v0.9.0.md b/docs/releases/v0.9.0.md new file mode 100644 index 0000000..860f3ae --- /dev/null +++ b/docs/releases/v0.9.0.md @@ -0,0 +1,11 @@ +# DevWiki v0.9.0 Release Notes + +Release date: 2026-06-02 + +## GitHub Release Notes + +## [0.9.0](https://github.com/geekgoing/devwiki/compare/v0.8.0...v0.9.0) (2026-06-02) + +### Features + +* 머지 후 화면과 검색 흐름 정리 ([088b083](https://github.com/geekgoing/devwiki/commit/088b08353e1b55997e4c61f966bbcdabcdb12979)) diff --git a/package.json b/package.json index 71d0a6f..772654b 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "verify:mvp-data": "node scripts/verify-devwiki-mvp-data.mjs", "verify:mvp-ui": "node scripts/verify-devwiki-mvp-ui.mjs", "verify:supabase": "node scripts/verify-supabase-readiness.mjs", + "release:sync-docs": "node scripts/sync-release-docs.mjs", "seed:concepts": "node scripts/seed-backend-interview-concepts.mjs" }, "dependencies": { diff --git a/scripts/sync-release-docs.mjs b/scripts/sync-release-docs.mjs new file mode 100644 index 0000000..21093d4 --- /dev/null +++ b/scripts/sync-release-docs.mjs @@ -0,0 +1,158 @@ +import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const RELEASE_DIR = path.join("docs", "releases"); + +function parseArgs(argv) { + const args = { + date: new Date().toISOString().slice(0, 10), + notesFile: null, + version: null, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const next = argv[index + 1]; + + if (arg === "--version") { + args.version = next; + index += 1; + continue; + } + + if (arg === "--notes-file") { + args.notesFile = next; + index += 1; + continue; + } + + if (arg === "--date") { + args.date = next; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + if (!args.version) { + throw new Error("Missing --version"); + } + + return args; +} + +function normalizeVersion(value) { + const trimmed = value.trim(); + const version = trimmed.startsWith("v") ? trimmed.slice(1) : trimmed; + + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid semver version: ${value}`); + } + + return { + tag: `v${version}`, + version, + }; +} + +function buildReleaseDoc({ date, notes, tag }) { + const releaseNotes = notes.trim() || "No release notes were generated."; + + return `# DevWiki ${tag} Release Notes + +Release date: ${date} + +## GitHub Release Notes + +${releaseNotes} +`; +} + +async function readReleaseNotes(notesFile) { + if (!notesFile) { + return ""; + } + + return readFile(notesFile, "utf8"); +} + +function updateReadmeReleaseLink(readme, tag) { + const section = `## Release notes + +- [${tag}](docs/releases/${tag}.md): latest release notes +`; + + if (readme.includes("## Release notes")) { + return readme.replace( + /## Release notes\n\n(?:- .+\n?)+/, + section, + ); + } + + const marker = "## Member management"; + + if (!readme.includes(marker)) { + return `${readme.trimEnd()}\n\n${section}`; + } + + return readme.replace(marker, `${section}\n${marker}`); +} + +async function renameLatestReleaseDocIfNeeded(targetPath, targetTag) { + let entries = []; + + try { + entries = await readdir(RELEASE_DIR); + } catch { + return; + } + + if (entries.includes(`${targetTag}.md`)) { + return; + } + + const releaseDocs = entries + .filter((entry) => /^v\d+\.\d+\.\d+\.md$/.test(entry)) + .sort((left, right) => + right.localeCompare(left, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + if (releaseDocs.length !== 1) { + return; + } + + await rename( + path.join(RELEASE_DIR, releaseDocs[0]), + targetPath, + ); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const { tag } = normalizeVersion(args.version); + const notes = await readReleaseNotes(args.notesFile); + const releasePath = path.join(RELEASE_DIR, `${tag}.md`); + + await mkdir(RELEASE_DIR, { recursive: true }); + await renameLatestReleaseDocIfNeeded(releasePath, tag); + await writeFile( + releasePath, + buildReleaseDoc({ + date: args.date, + notes, + tag, + }), + ); + + const readme = await readFile("README.md", "utf8"); + await writeFile("README.md", updateReadmeReleaseLink(readme, tag)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index 4f55e70..0389292 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -1,6 +1,8 @@ import { ArrowRight, BookOpen, + Clock3, + MessageSquare, MessageSquareText, Route, Search, @@ -27,9 +29,14 @@ import { documentDetailPath, } from "@/lib/content-routes"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; -import { getDocuments } from "@/lib/documents"; +import { getDocuments, getRecentDiscussions } from "@/lib/documents"; +import { formatDate } from "@/lib/format"; import { isSupabaseConfigured } from "@/lib/supabase/env"; -import type { DocumentContentType, DocumentSummary } from "@/types/devwiki"; +import type { + DocumentContentType, + DocumentSummary, + RecentDiscussion, +} from "@/types/devwiki"; const sectionCards = [ { @@ -127,17 +134,60 @@ function SmallDocumentLink({ document }: { document: DocumentSummary }) { ); } +function DiscussionDocumentLink({ + discussion, +}: { + discussion: RecentDiscussion; +}) { + const href = `${documentDetailPath(discussion.document)}#discussion`; + + return ( + + + {discussion.document.title} + + + {discussion.latestCommentBody} + + + {contentTypeLabels[discussion.document.contentType]} + + + {discussion.totalCommentCount}개 + + {discussion.replyCount ? ( + 답글 {discussion.replyCount}개 + ) : null} + + + + {discussion.latestCommentAuthorLabel} ·{" "} + {formatDate(discussion.latestCommentAt)} + + + ); +} + export default async function Home() { const configured = isSupabaseConfigured(); const user = await getCurrentUser(); const member = await getCurrentMember(); const canReadPrivate = !configured || Boolean(member); - const allDocuments = await getDocuments({ - status: "active", - canReadPrivate, - viewerId: user?.id, - }); + const [allDocuments, recentDiscussions] = await Promise.all([ + getDocuments({ + status: "active", + canReadPrivate, + viewerId: user?.id, + }), + getRecentDiscussions({ + canReadPrivate, + viewerId: user?.id, + }), + ]); const allFavoriteDocuments = allDocuments.filter( (document) => document.isFavorite, ); @@ -302,6 +352,33 @@ export default async function Home() { )} + + + + + + 최근 토론 + + + + {recentDiscussions.length ? ( + recentDiscussions.map((discussion) => ( + + )) + ) : ( +

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

+ )} +
+
diff --git a/src/components/document-comments.tsx b/src/components/document-comments.tsx index 3becb82..f59946e 100644 --- a/src/components/document-comments.tsx +++ b/src/components/document-comments.tsx @@ -343,7 +343,7 @@ export function DocumentComments({ } return ( - +
diff --git a/src/lib/comment-utils.test.ts b/src/lib/comment-utils.test.ts index 05cca2d..023ace7 100644 --- a/src/lib/comment-utils.test.ts +++ b/src/lib/comment-utils.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { countDocumentComments, getDocumentCommentStats, + getFlatDocumentCommentStats, } from "@/lib/comment-utils"; import type { DocumentComment } from "@/types/devwiki"; @@ -38,4 +39,18 @@ describe("comment utils", () => { }); expect(countDocumentComments(comments)).toBe(4); }); + + it("counts flat comment rows for discussion summaries", () => { + expect( + getFlatDocumentCommentStats([ + { parentCommentId: null }, + { parentCommentId: "comment-1" }, + { parentCommentId: "comment-1" }, + ]), + ).toEqual({ + replyCount: 2, + topLevelCount: 1, + totalCount: 3, + }); + }); }); diff --git a/src/lib/comment-utils.ts b/src/lib/comment-utils.ts index 54081be..fb6573d 100644 --- a/src/lib/comment-utils.ts +++ b/src/lib/comment-utils.ts @@ -33,3 +33,22 @@ export function getDocumentCommentStats( export function countDocumentComments(comments: DocumentComment[]): number { return getDocumentCommentStats(comments).totalCount; } + +export function getFlatDocumentCommentStats( + comments: Array<{ + parentCommentId: string | null; + }>, +): DocumentCommentStats { + return comments.reduce( + (stats, comment) => ({ + replyCount: stats.replyCount + (comment.parentCommentId ? 1 : 0), + topLevelCount: stats.topLevelCount + (comment.parentCommentId ? 0 : 1), + totalCount: stats.totalCount + 1, + }), + { + replyCount: 0, + topLevelCount: 0, + totalCount: 0, + }, + ); +} diff --git a/src/lib/documents.ts b/src/lib/documents.ts index ebaffc5..a94bf6e 100644 --- a/src/lib/documents.ts +++ b/src/lib/documents.ts @@ -1,6 +1,7 @@ import { unstable_cache } from "next/cache"; import { demoDocumentDetails, demoDocuments } from "@/lib/demo-data"; +import { getFlatDocumentCommentStats } from "@/lib/comment-utils"; import { isSupabaseConfigured } from "@/lib/supabase/env"; import { createAdminClient } from "@/lib/supabase/admin"; import { createClient } from "@/lib/supabase/server"; @@ -14,6 +15,7 @@ import type { DocumentStatus, DocumentStatusFilter, DocumentSummary, + RecentDiscussion, RelatedDocument, Tag, } from "@/types/devwiki"; @@ -64,12 +66,23 @@ type RawComment = { updated_by: string | null; }; +type RawRecentDiscussionComment = { + id: string; + body: string; + created_at: string; + created_by: string | null; + document_id: string; + parent_comment_id: string | null; + updated_at: string; +}; + const DOCUMENT_LIST_SELECT = "id, slug, title, summary, status, content_type, interview_category, created_at, updated_at, document_tags(tags(id, name, slug))"; 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 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))"; @@ -713,6 +726,164 @@ export async function getDocumentComments( return topLevelComments; } +async function getDiscussionCounts( + supabase: SupabaseReader, + documentIds: string[], +): Promise< + Map< + string, + { + replyCount: number; + totalCommentCount: number; + } + > +> { + const rowsByDocumentId = new Map< + string, + Array<{ + parentCommentId: string | null; + }> + >(); + + if (!documentIds.length) { + return new Map(); + } + + const { data, error } = await supabase + .from("comments") + .select("document_id, parent_comment_id") + .in("document_id", documentIds); + + if (error) { + throw new Error(error.message); + } + + (data ?? []).forEach((row) => { + const documentId = row.document_id as string | undefined; + + if (!documentId) { + return; + } + + const rows = rowsByDocumentId.get(documentId) ?? []; + rows.push({ + parentCommentId: (row.parent_comment_id as string | null) ?? null, + }); + rowsByDocumentId.set(documentId, rows); + }); + + return new Map( + Array.from(rowsByDocumentId.entries()).map(([documentId, rows]) => { + const stats = getFlatDocumentCommentStats(rows); + + return [ + documentId, + { + replyCount: stats.replyCount, + totalCommentCount: stats.totalCount, + }, + ]; + }), + ); +} + +export async function getRecentDiscussions({ + canReadPrivate = false, + limit = 4, + viewerId = null, +}: DocumentReadOptions & { + limit?: number; +} = {}): 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 latestCommentByDocumentId = new Map< + string, + RawRecentDiscussionComment + >(); + + rows.forEach((row) => { + if (!latestCommentByDocumentId.has(row.document_id)) { + latestCommentByDocumentId.set(row.document_id, row); + } + }); + + const documentIds = Array.from(latestCommentByDocumentId.keys()); + + if (!documentIds.length) { + return []; + } + + const documents = await attachDocumentStates( + await selectDocumentRowsByIds(supabase, documentIds), + viewerId, + ); + const visibleDocumentById = new Map( + documents + .filter((document) => + hasVisibleStatus(document, visibleStatuses("active", canReadPrivate)), + ) + .map((document) => [document.id, document]), + ); + const visibleDocumentIds = documentIds + .filter((documentId) => visibleDocumentById.has(documentId)) + .slice(0, Math.max(limit, 0)); + const [authorLabels, discussionCounts] = await Promise.all([ + getCommentAuthorLabels( + visibleDocumentIds + .map( + (documentId) => + latestCommentByDocumentId.get(documentId)?.created_by, + ) + .filter((id): id is string => Boolean(id)), + ), + getDiscussionCounts(supabase, visibleDocumentIds), + ]); + + return visibleDocumentIds.flatMap((documentId) => { + const document = visibleDocumentById.get(documentId); + const latestComment = latestCommentByDocumentId.get(documentId); + + if (!document || !latestComment) { + return []; + } + + const counts = discussionCounts.get(documentId) ?? { + replyCount: 0, + totalCommentCount: 0, + }; + + return [ + { + document, + latestCommentAt: latestComment.updated_at, + latestCommentAuthorLabel: latestComment.created_by + ? (authorLabels.get(latestComment.created_by) ?? + latestComment.created_by.slice(0, 8)) + : "알 수 없음", + latestCommentBody: latestComment.body, + replyCount: counts.replyCount, + totalCommentCount: counts.totalCommentCount, + }, + ]; + }); +} + export async function getRelatedDocuments( documentId: string, { canReadPrivate = false }: DocumentReadOptions = {}, diff --git a/src/types/devwiki.ts b/src/types/devwiki.ts index d77f04b..2db30bb 100644 --- a/src/types/devwiki.ts +++ b/src/types/devwiki.ts @@ -58,6 +58,15 @@ export type DocumentDetail = DocumentSummary & { export type RelatedDocument = DocumentSummary; +export type RecentDiscussion = { + document: DocumentSummary; + latestCommentAt: string; + latestCommentAuthorLabel: string; + latestCommentBody: string; + replyCount: number; + totalCommentCount: number; +}; + export type DocumentRevision = { id: string; title: string;