diff --git a/README.md b/README.md index 5fea0d8..548bc09 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Mermaid diagrams - Supabase Storage image uploads - Member-only access, role-based editing, profile nicknames -- Per-member favorites and completed-learning filters +- Per-member favorites - Database-backed full-text search with body snippets - Expanded, threaded document discussions - Share metadata, generated app icons, and document link copying @@ -34,8 +34,8 @@ Supabase 연결 전에는 데모 문서가 보입니다. 실제 저장을 사용 - `상황 시뮬레이션`: 서술형 상황 질문과 토론 Supabase가 연결된 환경에서는 로그인한 active member만 문서를 읽을 수 있습니다. `공개` 상태는 인터넷 공개가 아니라 전체 멤버 기본 목록에 노출된다는 뜻입니다. -홈은 통합 검색과 개인 학습 현황을 보여주는 메인 화면이고, `/terms`, `/interviews`, `/scenarios`에서 섹션별 문서를 탐색합니다. -각 멤버는 문서를 즐겨찾기하거나 `숙지함`으로 표시할 수 있고, 검색/섹션 화면에서 `즐겨찾기`, `숙지함`, `미숙지` 필터로 학습 상태를 나눠 볼 수 있습니다. +홈은 통합 검색과 개인 즐겨찾기 현황을 보여주는 메인 화면이고, `/terms`, `/interviews`, `/scenarios`에서 섹션별 문서를 탐색합니다. +각 멤버는 문서를 즐겨찾기할 수 있고, 검색/섹션 화면에서 즐겨찾기 문서만 필터링할 수 있습니다. ## Image uploads diff --git a/scripts/verify-devwiki-mvp-data.mjs b/scripts/verify-devwiki-mvp-data.mjs index 68ee362..2988ba7 100644 --- a/scripts/verify-devwiki-mvp-data.mjs +++ b/scripts/verify-devwiki-mvp-data.mjs @@ -486,7 +486,6 @@ ${imageMarkdown} { document_id: documentId, user_id: memberSession.user.id, - is_completed: true, is_favorite: true, }, { onConflict: "document_id,user_id" }, @@ -498,7 +497,7 @@ ${imageMarkdown} const { data: stateRows, error: stateLookupError } = await memberSession.client .from("document_member_states") - .select("is_favorite, is_completed") + .select("is_favorite") .eq("document_id", documentId) .eq("user_id", memberSession.user.id); @@ -510,7 +509,7 @@ ${imageMarkdown} ); } - if (!stateRows[0].is_favorite || !stateRows[0].is_completed) { + if (!stateRows[0].is_favorite) { throw new Error("Member document state values were not persisted."); } diff --git a/src/app/(protected)/interviews/page.tsx b/src/app/(protected)/interviews/page.tsx index eefcdcf..72062f6 100644 --- a/src/app/(protected)/interviews/page.tsx +++ b/src/app/(protected)/interviews/page.tsx @@ -3,7 +3,7 @@ import { ContentSectionPage } from "@/components/content-section-page"; type InterviewsPageProps = { searchParams: Promise<{ category?: string; - learning?: string; + favorites?: string; status?: string; }>; }; diff --git a/src/app/(protected)/me/page.tsx b/src/app/(protected)/me/page.tsx index b2bbddd..f30b73e 100644 --- a/src/app/(protected)/me/page.tsx +++ b/src/app/(protected)/me/page.tsx @@ -1,5 +1,4 @@ import { - CheckCircle2, KeyRound, MessageSquare, RefreshCw, @@ -56,14 +55,13 @@ type RecentCommentRow = { } | null; }; -type LearningDocumentRow = { +type FavoriteDocumentRow = { document: { content_type: string; slug: string; title: string; } | null; document_id: string; - is_completed: boolean; is_favorite: boolean; updated_at: string; }; @@ -83,7 +81,7 @@ type RawRecentCommentRow = Omit & { | null; }; -type RawLearningDocumentRow = Omit & { +type RawFavoriteDocumentRow = Omit & { documents?: | { content_type: string; @@ -147,15 +145,15 @@ async function getRecentComments(userId: string) { })); } -async function getLearningDocuments(userId: string) { +async function getFavoriteDocuments(userId: string) { const supabase = await createClient(); const { data, error } = await supabase .from("document_member_states") .select( - "document_id, is_favorite, is_completed, updated_at, documents(slug, title, content_type)", + "document_id, is_favorite, updated_at, documents(slug, title, content_type)", ) .eq("user_id", userId) - .or("is_favorite.eq.true,is_completed.eq.true") + .eq("is_favorite", true) .order("updated_at", { ascending: false }) .limit(8); @@ -163,12 +161,11 @@ async function getLearningDocuments(userId: string) { throw new Error(error.message); } - return ((data ?? []) as RawLearningDocumentRow[]).map((row) => ({ + return ((data ?? []) as RawFavoriteDocumentRow[]).map((row) => ({ document: Array.isArray(row.documents) ? (row.documents[0] ?? null) : (row.documents ?? null), document_id: row.document_id, - is_completed: row.is_completed, is_favorite: row.is_favorite, updated_at: row.updated_at, })); @@ -180,12 +177,12 @@ export default async function MePage({ searchParams }: MePageProps) { const user = await getCurrentUser(); const member = await getCurrentMember(); - const [recentDocuments, recentComments, learningDocuments] = + const [recentDocuments, recentComments, favoriteDocuments] = configured && user && member ? await Promise.all([ getRecentDocuments(user.id), getRecentComments(user.id), - getLearningDocuments(user.id), + getFavoriteDocuments(user.id), ]) : [[], [], []]; @@ -301,26 +298,20 @@ export default async function MePage({ searchParams }: MePageProps) {
- 내 학습 상태 + 즐겨찾기 - - {learningDocuments.length ? ( + {favoriteDocuments.length ? (
    - {learningDocuments.map((item) => ( + {favoriteDocuments.map((item) => (
  1. ) : null} - {item.is_completed ? ( - - - 숙지함 - - ) : null}
  2. @@ -359,7 +341,7 @@ export default async function MePage({ searchParams }: MePageProps) {
) : (

- 아직 즐겨찾기하거나 숙지 완료한 문서가 없습니다. + 아직 즐겨찾기한 문서가 없습니다.

)}
diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index 1858782..a307e23 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -1,7 +1,6 @@ import { ArrowRight, BookOpen, - CheckCircle2, MessageSquareText, Route, Search, @@ -10,7 +9,6 @@ import { import Link from "next/link"; import { DocumentListCard } from "@/components/document-list-card"; -import { LearningRouteBoard } from "@/components/learning-route-board"; import { SetupNotice } from "@/components/setup-notice"; import { Button } from "@/components/ui/button"; import { @@ -124,12 +122,6 @@ function SmallDocumentLink({ document }: { document: DocumentSummary }) { 즐겨찾기 ) : null} - {document.isCompleted ? ( - - - 숙지함 - - ) : null} ); @@ -149,9 +141,6 @@ export default async function Home() { const favoriteDocuments = allDocuments .filter((document) => document.isFavorite) .slice(0, 4); - const completedDocuments = allDocuments - .filter((document) => document.isCompleted) - .slice(0, 4); const recentDocuments = allDocuments.slice(0, 6); const counts = getDocumentCounts(allDocuments); const documentsByContentType = getDocumentsByContentType(allDocuments); @@ -195,38 +184,24 @@ export default async function Home() { - 내 학습 현황 + 즐겨찾기 - 즐겨찾기와 숙지함을 기준으로 다시 볼 문서를 고릅니다. + 다시 볼 문서를 빠르게 모아둡니다. -
- - - - 즐겨찾기 - - - {favoriteDocuments.length} - - - - - - 숙지함 - - - {completedDocuments.length} - - -
+ + + + 저장한 문서 + + + {favoriteDocuments.length} + +
@@ -309,7 +284,7 @@ export default async function Home() { 즐겨찾기 @@ -325,32 +300,8 @@ export default async function Home() { )} - - - - 숙지함 - - - - - - {completedDocuments.length ? ( - completedDocuments.map((document) => ( - - )) - ) : ( -

- 아직 숙지 완료한 문서가 없습니다. -

- )} -
-
- - ); diff --git a/src/app/(protected)/scenarios/page.tsx b/src/app/(protected)/scenarios/page.tsx index 27d4fd6..dd73c0d 100644 --- a/src/app/(protected)/scenarios/page.tsx +++ b/src/app/(protected)/scenarios/page.tsx @@ -2,7 +2,7 @@ import { ContentSectionPage } from "@/components/content-section-page"; type ScenariosPageProps = { searchParams: Promise<{ - learning?: string; + favorites?: string; status?: string; }>; }; diff --git a/src/app/(protected)/search/page.tsx b/src/app/(protected)/search/page.tsx index c39fcc6..5afd843 100644 --- a/src/app/(protected)/search/page.tsx +++ b/src/app/(protected)/search/page.tsx @@ -1,8 +1,8 @@ import { DocumentSearchClient } from "@/components/document-search-client"; import { SetupNotice } from "@/components/setup-notice"; import { + parseFavoritesFilter, parseInterviewCategory, - parseLearningFilter, parseStatusFilter, } from "@/lib/content-routes"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; @@ -13,7 +13,7 @@ import { isSupabaseConfigured } from "@/lib/supabase/env"; type SearchPageProps = { searchParams: Promise<{ category?: string; - learning?: string; + favorites?: string; q?: string; status?: string; }>; @@ -22,8 +22,8 @@ type SearchPageProps = { export default async function SearchPage({ searchParams }: SearchPageProps) { const params = await searchParams; const initialFilters = { + favoritesOnly: parseFavoritesFilter(params.favorites), interviewCategory: parseInterviewCategory(params.category), - learning: parseLearningFilter(params.learning), query: params.q?.trim() ?? "", status: parseStatusFilter(params.status), }; @@ -33,8 +33,8 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { const canReadPrivate = !configured || Boolean(member); const documents = hasActiveDocumentQuery(initialFilters) ? await getDocuments({ + favoritesOnly: initialFilters.favoritesOnly, interviewCategory: initialFilters.interviewCategory, - learning: initialFilters.learning, query: initialFilters.query, status: initialFilters.status, canReadPrivate, diff --git a/src/app/(protected)/terms/page.tsx b/src/app/(protected)/terms/page.tsx index 000f4a9..a5865ec 100644 --- a/src/app/(protected)/terms/page.tsx +++ b/src/app/(protected)/terms/page.tsx @@ -2,7 +2,7 @@ import { ContentSectionPage } from "@/components/content-section-page"; type TermsPageProps = { searchParams: Promise<{ - learning?: string; + favorites?: string; status?: string; }>; }; diff --git a/src/app/actions.ts b/src/app/actions.ts index 7c6e323..3081db3 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -102,11 +102,10 @@ const passwordSchema = z } }); -const documentLearningStateSchema = z.object({ +const documentFavoriteStateSchema = z.object({ contentType: z.enum(["term", "interview_qa", "scenario"]), documentId: z.string().uuid(), enabled: z.boolean(), - field: z.enum(["favorite", "completed"]), slug: z.string().trim().min(1), }); @@ -1012,41 +1011,29 @@ export async function updateMyPassword(formData: FormData) { redirect(next === "/me" ? "/me?notice=password" : next); } -export async function updateDocumentLearningState(formData: FormData) { +export async function updateDocumentFavoriteState(formData: FormData) { const { supabase, user } = await requireAuthenticatedMember(); - const parsed = documentLearningStateSchema.parse({ + const parsed = documentFavoriteStateSchema.parse({ contentType: readString(formData, "content_type") || "term", documentId: readString(formData, "document_id"), enabled: readString(formData, "enabled") === "1", - field: readString(formData, "field"), slug: readString(formData, "slug"), }); - const nextState = - parsed.field === "favorite" - ? { is_favorite: parsed.enabled } - : { is_completed: parsed.enabled }; - - const { data: currentState, error: currentStateError } = await supabase - .from("document_member_states") - .select("is_favorite, is_completed") - .eq("document_id", parsed.documentId) - .eq("user_id", user.id) - .maybeSingle(); - - if (currentStateError) { - throw new Error(currentStateError.message); - } - const { error } = await supabase.from("document_member_states").upsert( - { - document_id: parsed.documentId, - user_id: user.id, - is_favorite: currentState?.is_favorite ?? false, - is_completed: currentState?.is_completed ?? false, - ...nextState, - }, - { onConflict: "document_id,user_id" }, - ); + const { error } = parsed.enabled + ? await supabase.from("document_member_states").upsert( + { + document_id: parsed.documentId, + user_id: user.id, + is_favorite: true, + }, + { onConflict: "document_id,user_id" }, + ) + : await supabase + .from("document_member_states") + .delete() + .eq("document_id", parsed.documentId) + .eq("user_id", user.id); if (error) { throw new Error(error.message); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 62ac9e3..d345709 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -2,8 +2,8 @@ import type { NextRequest } from "next/server"; import { parseContentType, + parseFavoritesFilter, parseInterviewCategory, - parseLearningFilter, parseStatusFilter, } from "@/lib/content-routes"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; @@ -34,18 +34,18 @@ export async function GET(request: NextRequest) { const interviewCategory = parseInterviewCategory( searchParams.get("category") ?? undefined, ); - const learning = parseLearningFilter( - searchParams.get("learning") ?? undefined, + const favoritesOnly = parseFavoritesFilter( + searchParams.get("favorites") ?? undefined, ); const status = parseStatusFilter(searchParams.get("status") ?? undefined); const query = searchParams.get("q")?.trim() ?? ""; const documents = await getDocuments({ contentType, + favoritesOnly, interviewCategory: contentType && contentType !== "interview_qa" ? undefined : interviewCategory, - learning, query, status, canReadPrivate: !configured || Boolean(member), diff --git a/src/components/admin-members-client.tsx b/src/components/admin-members-client.tsx index c94a6a5..390723e 100644 --- a/src/components/admin-members-client.tsx +++ b/src/components/admin-members-client.tsx @@ -198,7 +198,7 @@ function MemberEditDialog({

owner는 멤버와 문서를 관리하고, editor는 문서를 수정하며, viewer는 - 읽기와 학습 기록만 사용할 수 있습니다. + 읽기, 댓글, 즐겨찾기만 사용할 수 있습니다.

diff --git a/src/components/content-section-page.tsx b/src/components/content-section-page.tsx index ac86ce6..edb1e84 100644 --- a/src/components/content-section-page.tsx +++ b/src/components/content-section-page.tsx @@ -1,8 +1,8 @@ import { DocumentCollectionClient } from "@/components/document-collection-client"; import { SetupNotice } from "@/components/setup-notice"; import { + parseFavoritesFilter, parseInterviewCategory, - parseLearningFilter, parseStatusFilter, } from "@/lib/content-routes"; import { getCurrentMember, getCurrentUser } from "@/lib/auth"; @@ -13,7 +13,7 @@ import type { DocumentContentType } from "@/types/devwiki"; type SectionSearchParams = Promise<{ category?: string; - learning?: string; + favorites?: string; status?: string; }>; @@ -27,8 +27,8 @@ export async function ContentSectionPage({ searchParams: SectionSearchParams; }) { const params = await searchParams; + const favoritesOnly = parseFavoritesFilter(params.favorites); const interviewCategory = parseInterviewCategory(params.category); - const learning = parseLearningFilter(params.learning); const status = parseStatusFilter(params.status); const configured = isSupabaseConfigured(); const user = await getCurrentUser(); @@ -38,9 +38,9 @@ export async function ContentSectionPage({ const canCreate = canEditContent(member); const documents = await getDocuments({ contentType, + favoritesOnly, interviewCategory: contentType === "interview_qa" ? interviewCategory : undefined, - learning, status, canReadPrivate, viewerId: user?.id, @@ -56,9 +56,9 @@ export async function ContentSectionPage({ initialDocuments={documents} initialFilters={{ contentType, + favoritesOnly, interviewCategory: contentType === "interview_qa" ? interviewCategory : undefined, - learning, query: "", status, }} diff --git a/src/components/document-collection-client.tsx b/src/components/document-collection-client.tsx index 2462eac..60e4106 100644 --- a/src/components/document-collection-client.tsx +++ b/src/components/document-collection-client.tsx @@ -105,13 +105,13 @@ export function DocumentCollectionClient({ const shouldShowDiscovery = contentType === "term" && currentFilters.status === "active" && - currentFilters.learning === "all" && + !currentFilters.favoritesOnly && !currentFilters.interviewCategory && !currentFilters.query; const shouldShowSectionBoard = (contentType === "interview_qa" || contentType === "scenario") && currentFilters.status === "active" && - currentFilters.learning === "all" && + !currentFilters.favoritesOnly && !currentFilters.interviewCategory && !currentFilters.query; const createHref = newDocumentHref( @@ -141,7 +141,7 @@ export function DocumentCollectionClient({ : undefined } contentType={contentType} - learning={currentFilters.learning} + favoritesOnly={currentFilters.favoritesOnly} onNavigate={(href) => window.history.pushState(null, "", href)} status={currentFilters.status} /> diff --git a/src/components/document-detail-page.tsx b/src/components/document-detail-page.tsx index f3c9a90..6ae2580 100644 --- a/src/components/document-detail-page.tsx +++ b/src/components/document-detail-page.tsx @@ -2,7 +2,6 @@ import { ArrowLeft, ArrowRight, CalendarDays, - CheckCircle2, Clock3, Link2, MessageSquare, @@ -15,7 +14,7 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; import type { ReactNode } from "react"; -import { updateDocumentLearningState } from "@/app/actions"; +import { updateDocumentFavoriteState } from "@/app/actions"; import { CopyLinkButton } from "@/components/copy-link-button"; import { DocumentComments } from "@/components/document-comments"; import { MarkdownRenderer } from "@/components/markdown-renderer"; @@ -192,31 +191,15 @@ function LinkSection({ ); } -function LearningStateButton({ - document, - field, -}: { - document: DocumentDetail; - field: "favorite" | "completed"; -}) { - const enabled = - field === "favorite" ? document.isFavorite : document.isCompleted; - const Icon = field === "favorite" ? Star : CheckCircle2; - const label = - field === "favorite" - ? enabled - ? "즐겨찾기 해제" - : "즐겨찾기" - : enabled - ? "숙지 취소" - : "숙지 완료"; +function FavoriteButton({ document }: { document: DocumentDetail }) { + const enabled = document.isFavorite; + const label = enabled ? "즐겨찾기 해제" : "즐겨찾기"; return ( -
+ -
@@ -285,7 +264,7 @@ export async function DocumentDetailPage({ getBacklinkDocuments(document.id, { canReadPrivate }), ]); const canContribute = canEditContent(member); - const canTrackLearning = Boolean(configured && member); + const canSaveFavorite = Boolean(configured && member); const shouldShowRelatedDocuments = relatedDocuments.length > 0 || canContribute; const commentCount = countDocumentComments(comments); @@ -327,11 +306,8 @@ export async function DocumentDetailPage({ ) : null}
- {canTrackLearning ? ( - <> - - - + {canSaveFavorite ? ( + ) : null} {canContribute ? ( diff --git a/src/components/document-filter-popover.tsx b/src/components/document-filter-popover.tsx index 9adbd61..dae0e6c 100644 --- a/src/components/document-filter-popover.tsx +++ b/src/components/document-filter-popover.tsx @@ -23,8 +23,8 @@ type FilterLink = { }; type DocumentFilterPopoverProps = { + favoriteLinks: FilterLink[]; interviewCategoryLinks?: FilterLink[]; - learningLinks: FilterLink[]; statusLinks: FilterLink[]; activeCount: number; activeLabels?: string[]; @@ -88,8 +88,8 @@ function FilterSection({ } export function DocumentFilterPopover({ + favoriteLinks, interviewCategoryLinks = [], - learningLinks, statusLinks, activeCount, activeLabels = [], @@ -172,10 +172,10 @@ export function DocumentFilterPopover({ /> ) : null} setOpen(false)} - title="학습 상태" + title="개인 필터" /> diff --git a/src/components/document-filter-toolbar.tsx b/src/components/document-filter-toolbar.tsx index 8931e96..6850e9f 100644 --- a/src/components/document-filter-toolbar.tsx +++ b/src/components/document-filter-toolbar.tsx @@ -1,13 +1,12 @@ import { DocumentFilterPopover } from "@/components/document-filter-popover"; import { + favoriteFilterOptions, interviewCategoryFilterOptions, - learningFilterOptions, statusFilterOptions, } from "@/lib/document-filters"; import { withSearchParams } from "@/lib/content-routes"; import type { DocumentContentType, - DocumentLearningFilter, DocumentStatusFilter, InterviewCategory, } from "@/types/devwiki"; @@ -15,35 +14,35 @@ import type { function sectionFilterHref({ basePath, category, - learning, + favoritesOnly, status, }: { basePath: string; category?: InterviewCategory; - learning: DocumentLearningFilter; + favoritesOnly: boolean; status: DocumentStatusFilter; }) { return withSearchParams(basePath, { category, - learning: learning === "all" ? undefined : learning, + favorites: favoritesOnly ? "1" : undefined, status: status === "active" ? undefined : status, }); } function searchFilterHref({ category, - learning, + favoritesOnly, query, status, }: { category?: InterviewCategory; - learning: DocumentLearningFilter; + favoritesOnly: boolean; query: string; status: DocumentStatusFilter; }) { return withSearchParams("/search", { category, - learning: learning === "all" ? undefined : learning, + favorites: favoritesOnly ? "1" : undefined, q: query || undefined, status: status === "active" ? undefined : status, }); @@ -53,7 +52,7 @@ export function DocumentFilterToolbar({ basePath, category, contentType, - learning, + favoritesOnly, onNavigate, query = "", status, @@ -61,7 +60,7 @@ export function DocumentFilterToolbar({ basePath: string; category?: InterviewCategory; contentType?: DocumentContentType; - learning: DocumentLearningFilter; + favoritesOnly: boolean; onNavigate?: (href: string) => void; query?: string; status: DocumentStatusFilter; @@ -69,24 +68,24 @@ export function DocumentFilterToolbar({ const isSearch = basePath === "/search"; const makeHref = ({ nextCategory = category, - nextLearning = learning, + nextFavoritesOnly = favoritesOnly, nextStatus = status, }: { nextCategory?: InterviewCategory; - nextLearning?: DocumentLearningFilter; + nextFavoritesOnly?: boolean; nextStatus?: DocumentStatusFilter; }) => isSearch ? searchFilterHref({ category: nextCategory, - learning: nextLearning, + favoritesOnly: nextFavoritesOnly, query, status: nextStatus, }) : sectionFilterHref({ basePath, category: nextCategory, - learning: nextLearning, + favoritesOnly: nextFavoritesOnly, status: nextStatus, }); @@ -95,10 +94,10 @@ export function DocumentFilterToolbar({ label: option.label, selected: status === option.value, })); - const learningLinks = learningFilterOptions.map((option) => ({ - href: makeHref({ nextLearning: option.value }), + const favoriteLinks = favoriteFilterOptions.map((option) => ({ + href: makeHref({ nextFavoritesOnly: option.value }), label: option.label, - selected: learning === option.value, + selected: favoritesOnly === option.value, })); const shouldShowInterviewCategory = isSearch || contentType === "interview_qa"; @@ -112,8 +111,8 @@ export function DocumentFilterToolbar({ const statusLabel = statusFilterOptions.find( (option) => option.value === status, )?.label; - const learningLabel = learningFilterOptions.find( - (option) => option.value === learning, + const favoriteLabel = favoriteFilterOptions.find( + (option) => option.value === favoritesOnly, )?.label; const categoryLabel = category ? interviewCategoryFilterOptions.find((option) => option.value === category) @@ -121,12 +120,12 @@ export function DocumentFilterToolbar({ : undefined; const activeFilterLabels = [ status !== "active" && statusLabel ? `상태: ${statusLabel}` : undefined, - learning !== "all" && learningLabel ? `학습: ${learningLabel}` : undefined, + favoritesOnly && favoriteLabel ? favoriteLabel : undefined, categoryLabel ? `분류: ${categoryLabel}` : undefined, ].filter((label): label is string => Boolean(label)); const activeFilterCount = Number(status !== "active") + - Number(learning !== "all") + + Number(favoritesOnly) + Number(Boolean(category)); const resetHref = isSearch ? withSearchParams("/search", { q: query || undefined }) @@ -136,8 +135,8 @@ export function DocumentFilterToolbar({ ) : null} - {document.isCompleted ? ( - - - 숙지함 - - ) : null}
{description ? (

diff --git a/src/components/document-search-client.tsx b/src/components/document-search-client.tsx index f8ee0e8..fcfddd8 100644 --- a/src/components/document-search-client.tsx +++ b/src/components/document-search-client.tsx @@ -54,7 +54,7 @@ function isInitialFilters( function searchHref(filters: DocumentQueryFilters) { return withSearchParams("/search", { category: filters.interviewCategory, - learning: filters.learning === "all" ? undefined : filters.learning, + favorites: filters.favoritesOnly ? "1" : undefined, q: filters.query || undefined, status: filters.status === "active" ? undefined : filters.status, }); @@ -157,7 +157,7 @@ export function DocumentSearchClient({ window.history.pushState(null, "", href)} query={currentFilters.query} status={currentFilters.status} diff --git a/src/components/learning-route-board.tsx b/src/components/learning-route-board.tsx deleted file mode 100644 index 55783a3..0000000 --- a/src/components/learning-route-board.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { ArrowRight, CheckCircle2, GraduationCap } from "lucide-react"; -import Link from "next/link"; - -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { contentTypeLabels, documentDetailPath } from "@/lib/content-routes"; -import type { DocumentSummary } from "@/types/devwiki"; - -const learningRoutes = [ - { - title: "분산 트랜잭션 답변 루트", - summary: "MSA 정합성 문제를 개념에서 상황 답변까지 이어갑니다.", - slugs: [ - "msa", - "saga", - "compensation", - "eventual-consistency", - "outbox", - "qa-api-gateway", - "scenario-msa-order-payment-cancel", - ], - }, - { - title: "장애 대응 면접 루트", - summary: "중복 요청, 재시도, 격리, 큐 실패까지 운영 관점으로 봅니다.", - slugs: [ - "idempotency", - "timeout-retry", - "circuit-breaker", - "backpressure", - "qa-message-queue-retry", - "scenario-retry-timeout-incident", - "scenario-dlq-poison-message", - ], - }, - { - title: "캐시 / 성능 운영 루트", - summary: "캐시 전략과 부하 제어를 실제 장애 시나리오로 연결합니다.", - slugs: [ - "cache-aside", - "cache-invalidation", - "cache-stampede", - "rate-limiting", - "qa-cache-invalidation", - "scenario-cache-stampede-hot-key", - ], - }, - { - title: "웹 요청 흐름 루트", - summary: "브라우저 요청부터 인증, Gateway까지 한 번에 정리합니다.", - slugs: [ - "tcp-handshake", - "http-keep-alive", - "cors", - "session-cookie", - "jwt", - "oauth2", - "api-gateway", - "qa-api-gateway", - "scenario-browser-naver-flow", - ], - }, -] satisfies Array<{ - title: string; - summary: string; - slugs: string[]; -}>; - -function documentHref(document: DocumentSummary) { - return documentDetailPath({ - contentType: document.contentType, - slug: document.slug, - }); -} - -function pickRouteDocuments( - documentBySlug: Map, - slugs: string[], -) { - return slugs - .map((slug) => documentBySlug.get(slug)) - .filter((document): document is DocumentSummary => Boolean(document)); -} - -function RouteStep({ - document, - index, -}: { - document: DocumentSummary; - index: number; -}) { - return ( -

  • - - - {index + 1} - - - - {document.title} - - - {contentTypeLabels[document.contentType]} - - - {document.isCompleted ? ( - - ) : ( - - )} - -
  • - ); -} - -export function LearningRouteBoard({ - documents, -}: { - documents: DocumentSummary[]; -}) { - const documentBySlug = new Map( - documents.map((document) => [document.slug, document]), - ); - const routes = learningRoutes - .map((route) => ({ - ...route, - documents: pickRouteDocuments(documentBySlug, route.slugs), - })) - .filter((route) => route.documents.length); - - if (!routes.length) { - return null; - } - - return ( -
    -
    -
    -

    추천 학습 루트

    -

    - 개념, 면접 답변, 상황 적용을 한 흐름으로 이어서 봅니다. -

    -
    - 교육 루트 -
    - -
    - {routes.map((route) => { - const completedCount = route.documents.filter( - (document) => document.isCompleted, - ).length; - const nextDocument = - route.documents.find((document) => !document.isCompleted) ?? - route.documents[0]; - const progressText = `${completedCount}/${route.documents.length} 완료`; - - return ( - - -
    -
    -

    {route.title}

    -

    - {route.summary} -

    -
    - - - -
    - -
    - - {progressText} - - -
    - -
      - {route.documents.slice(0, 5).map((document, index) => ( - - ))} -
    -
    -
    - ); - })} -
    -
    - ); -} diff --git a/src/lib/content-routes.ts b/src/lib/content-routes.ts index 6bd2377..9112997 100644 --- a/src/lib/content-routes.ts +++ b/src/lib/content-routes.ts @@ -1,6 +1,5 @@ import type { DocumentContentType, - DocumentLearningFilter, DocumentStatusFilter, InterviewCategory, } from "@/types/devwiki"; @@ -76,10 +75,8 @@ export function parseInterviewCategory( return value === "technical" || value === "behavioral" ? value : undefined; } -export function parseLearningFilter(value?: string): DocumentLearningFilter { - return value === "favorite" || value === "completed" || value === "todo" - ? value - : "all"; +export function parseFavoritesFilter(value?: string): boolean { + return value === "1" || value === "true" || value === "favorite"; } export function withSearchParams( diff --git a/src/lib/demo-data.ts b/src/lib/demo-data.ts index 43fc451..292722f 100644 --- a/src/lib/demo-data.ts +++ b/src/lib/demo-data.ts @@ -12,7 +12,6 @@ export const demoDocuments: DocumentSummary[] = [ contentType: "term", interviewCategory: null, isFavorite: false, - isCompleted: false, createdAt: now, updatedAt: now, tags: [ @@ -29,7 +28,6 @@ export const demoDocuments: DocumentSummary[] = [ contentType: "term", interviewCategory: null, isFavorite: false, - isCompleted: false, createdAt: now, updatedAt: now, tags: [ diff --git a/src/lib/document-filters.ts b/src/lib/document-filters.ts index 44fb874..7009a32 100644 --- a/src/lib/document-filters.ts +++ b/src/lib/document-filters.ts @@ -1,17 +1,14 @@ import type { - DocumentLearningFilter, DocumentStatusFilter, InterviewCategory, } from "@/types/devwiki"; -export const learningFilterOptions: Array<{ +export const favoriteFilterOptions: Array<{ label: string; - value: DocumentLearningFilter; + value: boolean; }> = [ - { label: "전체", value: "all" }, - { label: "즐겨찾기", value: "favorite" }, - { label: "숙지함", value: "completed" }, - { label: "미숙지", value: "todo" }, + { label: "전체", value: false }, + { label: "즐겨찾기", value: true }, ]; export const statusFilterOptions: Array<{ diff --git a/src/lib/document-query.test.ts b/src/lib/document-query.test.ts index c97fe0c..03ed79c 100644 --- a/src/lib/document-query.test.ts +++ b/src/lib/document-query.test.ts @@ -13,14 +13,14 @@ function params(value: string) { describe("document query filters", () => { it("drops interview category outside interview documents", () => { const filters = readDocumentQueryFilters( - params("category=behavioral&learning=favorite&q=cache"), + params("category=behavioral&favorites=1&q=cache"), "term", ); expect(filters).toMatchObject({ contentType: "term", + favoritesOnly: true, interviewCategory: undefined, - learning: "favorite", query: "cache", status: "active", }); @@ -31,19 +31,19 @@ describe("document query filters", () => { documentApiPath({ contentType: "interview_qa", interviewCategory: "technical", - learning: "todo", + favoritesOnly: true, query: "redis", status: "draft", }), ).toBe( - "/api/documents?content_type=interview_qa&category=technical&learning=todo&status=draft&q=redis", + "/api/documents?content_type=interview_qa&category=technical&favorites=1&status=draft&q=redis", ); }); it("detects empty search pages", () => { expect( hasActiveDocumentQuery({ - learning: "all", + favoritesOnly: false, query: "", status: "active", }), diff --git a/src/lib/document-query.ts b/src/lib/document-query.ts index af7158a..bce260c 100644 --- a/src/lib/document-query.ts +++ b/src/lib/document-query.ts @@ -1,11 +1,10 @@ import { + parseFavoritesFilter, parseInterviewCategory, - parseLearningFilter, parseStatusFilter, } from "@/lib/content-routes"; import type { DocumentContentType, - DocumentLearningFilter, DocumentStatusFilter, InterviewCategory, } from "@/types/devwiki"; @@ -16,8 +15,8 @@ type SearchParamReader = { export type DocumentQueryFilters = { contentType?: DocumentContentType; + favoritesOnly: boolean; interviewCategory?: InterviewCategory; - learning: DocumentLearningFilter; query: string; status: DocumentStatusFilter; }; @@ -32,11 +31,13 @@ export function readDocumentQueryFilters( return { contentType, + favoritesOnly: parseFavoritesFilter( + searchParams.get("favorites") ?? undefined, + ), interviewCategory: contentType && contentType !== "interview_qa" ? undefined : interviewCategory, - learning: parseLearningFilter(searchParams.get("learning") ?? undefined), query: searchParams.get("q")?.trim() ?? "", status: parseStatusFilter(searchParams.get("status") ?? undefined), }; @@ -46,7 +47,7 @@ export function documentQueryCacheKey(filters: DocumentQueryFilters) { return { category: filters.interviewCategory ?? null, contentType: filters.contentType ?? null, - learning: filters.learning, + favoritesOnly: filters.favoritesOnly, query: filters.query, status: filters.status, }; @@ -63,8 +64,8 @@ export function documentApiPath(filters: DocumentQueryFilters) { params.set("category", filters.interviewCategory); } - if (filters.learning !== "all") { - params.set("learning", filters.learning); + if (filters.favoritesOnly) { + params.set("favorites", "1"); } if (filters.status !== "active") { @@ -82,8 +83,8 @@ export function documentApiPath(filters: DocumentQueryFilters) { export function hasActiveDocumentQuery(filters: DocumentQueryFilters) { return ( Boolean(filters.query) || + filters.favoritesOnly || Boolean(filters.interviewCategory) || - filters.learning !== "all" || filters.status !== "active" ); } diff --git a/src/lib/documents.ts b/src/lib/documents.ts index be679ed..ebaffc5 100644 --- a/src/lib/documents.ts +++ b/src/lib/documents.ts @@ -8,8 +8,7 @@ import type { DocumentComment, DocumentContentType, DocumentDetail, - DocumentLearningFilter, - DocumentLearningState, + DocumentMemberState, InterviewCategory, DocumentRevision, DocumentStatus, @@ -46,7 +45,6 @@ type RawDocumentLink = { type RawDocumentMemberState = { document_id: string; - is_completed: boolean; is_favorite: boolean; }; @@ -86,8 +84,8 @@ type DocumentReadOptions = { type DocumentListOptions = DocumentReadOptions & { contentType?: DocumentContentType; + favoritesOnly?: boolean; interviewCategory?: InterviewCategory; - learning?: DocumentLearningFilter; query?: string; status?: DocumentStatusFilter; }; @@ -119,8 +117,7 @@ function flattenTags(relations?: RawTagRelation[] | null): Tag[] { function toDocumentSummary( row: RawDocument, - state: DocumentLearningState = { - isCompleted: false, + state: DocumentMemberState = { isFavorite: false, }, searchSnippet?: string | null, @@ -134,7 +131,6 @@ function toDocumentSummary( status: row.status, contentType: row.content_type ?? "term", interviewCategory: row.interview_category ?? null, - isCompleted: state.isCompleted, isFavorite: state.isFavorite, createdAt: row.created_at, updatedAt: row.updated_at, @@ -379,27 +375,12 @@ function hasVisibleStatus( return statuses.includes(document.status); } -function matchesLearningFilter( - document: DocumentSummary, - learning: DocumentLearningFilter = "all", -) { - if (learning === "favorite") { - return document.isFavorite; - } - - if (learning === "completed") { - return document.isCompleted; - } - - if (learning === "todo") { - return !document.isCompleted; - } - - return true; +function matchesFavoriteFilter(document: DocumentSummary, favoritesOnly = false) { + return !favoritesOnly || document.isFavorite; } async function getDocumentStateMap(documentIds: string[], viewerId?: string | null) { - const stateMap = new Map(); + const stateMap = new Map(); if (!viewerId || !documentIds.length || !isSupabaseConfigured()) { return stateMap; @@ -408,7 +389,7 @@ async function getDocumentStateMap(documentIds: string[], viewerId?: string | nu const supabase = await createClient(); const { data, error } = await supabase .from("document_member_states") - .select("document_id, is_favorite, is_completed") + .select("document_id, is_favorite") .eq("user_id", viewerId) .in("document_id", documentIds); @@ -418,7 +399,6 @@ async function getDocumentStateMap(documentIds: string[], viewerId?: string | nu ((data ?? []) as RawDocumentMemberState[]).forEach((row) => { stateMap.set(row.document_id, { - isCompleted: row.is_completed, isFavorite: row.is_favorite, }); }); @@ -507,8 +487,8 @@ async function getCommentAuthorLabels(createdByIds: string[]) { export async function getDocuments({ contentType, + favoritesOnly = false, interviewCategory, - learning = "all", query = "", status = "active", canReadPrivate = false, @@ -527,7 +507,7 @@ export async function getDocuments({ (!contentType || document.contentType === contentType) && (!interviewCategory || document.interviewCategory === interviewCategory) && - matchesLearningFilter(document, learning) && + matchesFavoriteFilter(document, favoritesOnly) && matchesQuery(document, query), ); } @@ -555,11 +535,15 @@ export async function getDocuments({ ); if (searched) { - return attachDocumentStates( + const documents = await attachDocumentStates( searched.rows, viewerId, searched.snippetById, ); + + return documents.filter((document) => + matchesFavoriteFilter(document, favoritesOnly), + ); } } @@ -571,7 +555,7 @@ export async function getDocuments({ return documents.filter( (document) => - matchesLearningFilter(document, learning) && + matchesFavoriteFilter(document, favoritesOnly) && matchesQuery(document, normalizedQuery), ); } @@ -611,7 +595,6 @@ export async function getDocumentBySlug( return { ...toDocumentDetail(data as RawDocument), ...(stateMap.get(data.id) ?? { - isCompleted: false, isFavorite: false, }), }; diff --git a/src/types/devwiki.ts b/src/types/devwiki.ts index 4403647..d77f04b 100644 --- a/src/types/devwiki.ts +++ b/src/types/devwiki.ts @@ -30,15 +30,9 @@ export type DocumentStatus = "draft" | "published" | "archived"; export type DocumentStatusFilter = "active" | DocumentStatus; export type DocumentContentType = "term" | "interview_qa" | "scenario"; export type InterviewCategory = "technical" | "behavioral"; -export type DocumentLearningFilter = - | "all" - | "favorite" - | "completed" - | "todo"; -export type DocumentLearningState = { +export type DocumentMemberState = { isFavorite: boolean; - isCompleted: boolean; }; export type DocumentSummary = { @@ -51,7 +45,6 @@ export type DocumentSummary = { contentType: DocumentContentType; interviewCategory: InterviewCategory | null; isFavorite: boolean; - isCompleted: boolean; updatedAt: string; createdAt: string; tags: Tag[]; diff --git a/supabase/README.md b/supabase/README.md index f8ed46d..ea9745f 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -43,6 +43,7 @@ Supabase SQL Editor에서 `supabase/migrations`의 SQL 파일을 파일명 순 8. `20260526131913_search_comments_conflicts_tests.sql` 9. `20260602104500_remove_comment_resolution.sql` 10. `20260602110000_add_comment_replies.sql` +11. `20260602134005_remove_completed_document_state.sql` 이미 첫 migration을 적용했다면 두 번째 이후 migration만 추가로 실행하면 됩니다. 두 번째 migration은 `verify:*` 스크립트가 사용하는 `service_role`이 @@ -52,13 +53,15 @@ Supabase Data API에서 public 테이블에 접근할 수 있도록 명시적으 남도록 트리거를 보강합니다. 다섯 번째 migration은 문서 간 연관 관계를 추가합니다. 여섯 번째 migration은 문서 콘텐츠 타입, 회원 전용 프로필 수정, editor 이상 쓰기 권한 정책을 추가합니다. -일곱 번째 migration은 멤버별 즐겨찾기와 숙지 완료 상태를 저장하는 +일곱 번째 migration은 멤버별 문서 상태를 저장하는 `document_member_states` 테이블과 RLS 정책을 추가합니다. 여덟 번째 migration은 문서 본문 검색용 `search_vector`와 `search_documents` RPC, 댓글 수정 추적과 editor/author 댓글 관리 정책을 추가합니다. 아홉 번째 migration은 토론을 일반 대화 공간으로 유지하기 위해 댓글 해결 상태 컬럼을 제거합니다. 열 번째 migration은 댓글에 1단계 대댓글 관계를 추가하고, 대댓글이 같은 문서의 원댓글에만 달리도록 검증합니다. +열한 번째 migration은 숙지 완료 상태 컬럼과 관련 인덱스를 제거하고, +즐겨찾기하지 않은 멤버별 문서 상태 row를 정리합니다. 프로젝트 루트의 `.mcp.json`은 Supabase Remote MCP 서버를 가리킵니다. Codex/Claude 같은 MCP 클라이언트에서 `supabase` 서버를 인증하면, SQL Editor @@ -155,7 +158,7 @@ DEVWIKI_E2E_EMAIL="you@example.com" DEVWIKI_E2E_PASSWORD="change-this-password" `verify:mvp-data`는 `SUPABASE_SERVICE_ROLE_KEY`가 있을 때만 실행됩니다. 이 명령은 이메일+비밀번호로 테스트 세션을 만들고, 멤버/비멤버 권한, 문서 생성/수정, Markdown 원문 보존, tag 갱신, 이미지 업로드, revision 생성을 -검증합니다. 멤버별 즐겨찾기/숙지 완료 row도 함께 검증합니다. +검증합니다. 멤버별 즐겨찾기 row도 함께 검증합니다. 테스트 문서, 이미지, tag, 임시 비멤버 auth user는 실행 후 삭제합니다. 테스트 이메일을 `members`와 Supabase Auth에 자동 등록하거나 비밀번호를 동기화하려면 `DEVWIKI_E2E_MANAGE_MEMBER=1`을 함께 설정합니다. diff --git a/supabase/migrations/20260523081322_document_member_learning_states.sql b/supabase/migrations/20260523081322_document_member_learning_states.sql index 66f23c0..e61e772 100644 --- a/supabase/migrations/20260523081322_document_member_learning_states.sql +++ b/supabase/migrations/20260523081322_document_member_learning_states.sql @@ -2,7 +2,6 @@ create table if not exists public.document_member_states ( document_id uuid not null references public.documents(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, is_favorite boolean not null default false, - is_completed boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (document_id, user_id) @@ -12,10 +11,6 @@ create index if not exists document_member_states_user_favorite_idx on public.document_member_states (user_id, is_favorite, updated_at desc) where is_favorite; -create index if not exists document_member_states_user_completed_idx -on public.document_member_states (user_id, is_completed, updated_at desc) -where is_completed; - drop trigger if exists set_document_member_states_updated_at on public.document_member_states; diff --git a/supabase/migrations/20260602134005_remove_completed_document_state.sql b/supabase/migrations/20260602134005_remove_completed_document_state.sql new file mode 100644 index 0000000..90e8640 --- /dev/null +++ b/supabase/migrations/20260602134005_remove_completed_document_state.sql @@ -0,0 +1,7 @@ +delete from public.document_member_states +where not is_favorite; + +drop index if exists public.document_member_states_user_completed_idx; + +alter table public.document_member_states +drop column if exists is_completed;