From b3a75c65986b442a877dbc5f4ec52b79dfe1f68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Tue, 23 Jun 2026 17:25:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(series):=20=EC=8B=9C=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=ED=83=90=EC=83=89=20=EA=B2=BD=ED=97=98=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/translations.ts | 51 ++++++ src/lib/posts.ts | 52 +++++- src/pages/SeriesPage.tsx | 381 ++++++++++++++++++++++++++++++++++----- 3 files changed, 428 insertions(+), 56 deletions(-) diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 203d9d5..9271280 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -77,8 +77,25 @@ export interface Translations { } series: { description: string + summary: (postCount: number, seriesCount: number) => string + searchPlaceholder: string + sortRecent: string + sortCount: string + sortName: string + reset: string + selectedLabel: (series: string) => string + topSeries: string + topSeriesNote: string + topicGroups: string + ungrouped: string postCount: (count: number, date: string) => string + postCountShort: (count: number) => string + fallbackDescription: (series: string) => string + readingPath: string + relatedTags: string + startReading: string noSeries: string + noMatchingSeries: string allSeries: string noPostsInSeries: string } @@ -270,8 +287,25 @@ export const ko: Translations = { }, series: { description: "시리즈별 블로그 글 목록", + summary: (postCount, seriesCount) => `${postCount}개의 글이 ${seriesCount}개의 시리즈로 정리되어 있습니다.`, + searchPlaceholder: "시리즈, 태그, 설명으로 검색", + sortRecent: "최근순", + sortCount: "글 많은순", + sortName: "이름순", + reset: "초기화", + selectedLabel: (series) => `선택됨 · ${series}`, + topSeries: "주요 시리즈", + topSeriesNote: "최근 업데이트와 글 수 기준", + topicGroups: "주제 그룹", + ungrouped: "기타", postCount: (count, date) => `${count}개의 글 · ${date}`, + postCountShort: (count) => `${count}편`, + fallbackDescription: (series) => `${series} 시리즈의 글을 순서대로 모아 봅니다.`, + readingPath: "읽기 순서", + relatedTags: "관련 태그", + startReading: "첫 글부터 읽기", noSeries: "시리즈가 없습니다.", + noMatchingSeries: "조건에 맞는 시리즈가 없습니다.", allSeries: "전체 시리즈", noPostsInSeries: "해당 시리즈의 글이 없습니다.", }, @@ -463,8 +497,25 @@ export const en: Translations = { }, series: { description: "Blog posts by series", + summary: (postCount, seriesCount) => `${postCount} post${postCount !== 1 ? "s" : ""} are organized into ${seriesCount} series.`, + searchPlaceholder: "Search series, tags, or descriptions", + sortRecent: "Recent", + sortCount: "Most posts", + sortName: "A-Z", + reset: "Reset", + selectedLabel: (series) => `Selected · ${series}`, + topSeries: "Top Series", + topSeriesNote: "Sorted by recent updates and post count", + topicGroups: "Topic Groups", + ungrouped: "Other", postCount: (count, date) => `${count} post${count !== 1 ? "s" : ""} · ${date}`, + postCountShort: (count) => `${count} post${count !== 1 ? "s" : ""}`, + fallbackDescription: (series) => `Read the ${series} series in order.`, + readingPath: "Reading Path", + relatedTags: "Related Tags", + startReading: "Start reading", noSeries: "No series yet.", + noMatchingSeries: "No series match these filters.", allSeries: "All series", noPostsInSeries: "No posts in this series.", }, diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 047b6b9..5e5f7b3 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -198,32 +198,68 @@ export interface SeriesInfo { name: string postCount: number firstDate: string + latestDate: string + description: string + tags: string[] + posts: PostMeta[] } export function getAllSeries(language: Language = "ko"): SeriesInfo[] { const posts = getAllPosts(language) - const seriesMap = new Map() + const seriesMap = new Map() for (const post of posts) { if (!post.series) continue const existing = seriesMap.get(post.series) if (existing) { - existing.count++ + existing.posts.push(post) if (post.date < existing.firstDate) existing.firstDate = post.date + if (post.date > existing.latestDate) existing.latestDate = post.date } else { - seriesMap.set(post.series, { count: 1, firstDate: post.date }) + seriesMap.set(post.series, { posts: [post], firstDate: post.date, latestDate: post.date }) } } return [...seriesMap.entries()] - .map(([name, { count, firstDate }]) => ({ name, postCount: count, firstDate })) - .sort((a, b) => (a.firstDate > b.firstDate ? -1 : 1)) + .map(([name, { posts, firstDate, latestDate }]) => { + const orderedPosts = sortSeriesPosts(posts) + const tagCounts = new Map() + for (const post of orderedPosts) { + for (const tag of post.tags) { + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) + } + } + + return { + name, + postCount: orderedPosts.length, + firstDate, + latestDate, + description: orderedPosts[0]?.description ?? "", + tags: [...tagCounts.entries()] + .sort(([a, aCount], [b, bCount]) => bCount - aCount || a.localeCompare(b)) + .map(([tag]) => tag), + posts: orderedPosts, + } + }) + .sort((a, b) => { + if (a.latestDate !== b.latestDate) return b.latestDate.localeCompare(a.latestDate) + return a.name.localeCompare(b.name) + }) } export function getSeriesPosts(seriesName: string, language: Language = "ko"): PostMeta[] { - return getAllPosts(language) - .filter((p) => p.series === seriesName) - .sort((a, b) => (a.seriesOrder ?? 0) - (b.seriesOrder ?? 0)) + return sortSeriesPosts(getAllPosts(language).filter((p) => p.series === seriesName)) +} + +function sortSeriesPosts(posts: PostMeta[]): PostMeta[] { + return [...posts].sort((a, b) => { + const aOrder = a.seriesOrder ?? Number.MAX_SAFE_INTEGER + const bOrder = b.seriesOrder ?? Number.MAX_SAFE_INTEGER + if (aOrder !== bOrder) return aOrder - bOrder + if (a.date !== b.date) return a.date > b.date ? -1 : 1 + return a.title.localeCompare(b.title) + }) } export function getAdjacentPosts(slug: string, language: Language = "ko"): { prev: PostMeta | null; next: PostMeta | null } { diff --git a/src/pages/SeriesPage.tsx b/src/pages/SeriesPage.tsx index 459c838..aa13269 100644 --- a/src/pages/SeriesPage.tsx +++ b/src/pages/SeriesPage.tsx @@ -1,73 +1,358 @@ +import { useMemo } from "react" import { Link, useSearchParams } from "react-router-dom" +import { SearchIcon, XIcon } from "lucide-react" import { useMetaTags } from "@/hooks/useMetaTags" -import { getAllSeries, getSeriesPosts } from "@/lib/posts" +import { getAllPosts, getAllSeries, type SeriesInfo } from "@/lib/posts" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { PostList } from "@/components/PostList" +import { Input } from "@/components/ui/input" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { PageContainer } from "@/components/PageContainer" import { useLanguage } from "@/i18n" -import { localizePath } from "@/lib/i18n-routing" +import { localizePath, postPath } from "@/lib/i18n-routing" + +type SeriesSort = "recent" | "count" | "name" + +interface SeriesGroup { + id: string + label: string + tags: string[] +} + +const seriesSorts = new Set(["recent", "count", "name"]) + +const SERIES_GROUPS: SeriesGroup[] = [ + { + id: "backend-auth", + label: "Backend / Auth", + tags: ["authentication", "oauth", "jwt", "payment", "java"], + }, + { + id: "infra", + label: "Infra / DevOps", + tags: ["devops", "kubernetes", "migration", "aws", "gcp", "observability", "argocd", "gitops"], + }, + { + id: "frontend-ai", + label: "Frontend / AI", + tags: ["react", "typescript", "blog", "seo", "ux", "ai", "spring-ai", "llm"], + }, +] + +function toSeriesSort(value: string | null): SeriesSort { + return seriesSorts.has(value as SeriesSort) ? (value as SeriesSort) : "recent" +} + +function sortSeries(series: SeriesInfo[], sortMode: SeriesSort) { + return [...series].sort((a, b) => { + if (sortMode === "name") return a.name.localeCompare(b.name) + if (sortMode === "count") { + if (a.postCount !== b.postCount) return b.postCount - a.postCount + if (a.latestDate !== b.latestDate) return b.latestDate.localeCompare(a.latestDate) + return a.name.localeCompare(b.name) + } + + if (a.latestDate !== b.latestDate) return b.latestDate.localeCompare(a.latestDate) + if (a.postCount !== b.postCount) return b.postCount - a.postCount + return a.name.localeCompare(b.name) + }) +} + +function matchesQuery(series: SeriesInfo, query: string) { + const q = query.toLowerCase() + return ( + series.name.toLowerCase().includes(q) || + series.description.toLowerCase().includes(q) || + series.tags.some((tag) => tag.toLowerCase().includes(q)) || + series.posts.some((post) => ( + post.title.toLowerCase().includes(q) || + post.description.toLowerCase().includes(q) + )) + ) +} + +function getGroupScore(series: SeriesInfo, group: SeriesGroup) { + const groupTags = new Set(group.tags) + return series.tags.reduce((score, tag) => score + (groupTags.has(tag) ? 1 : 0), 0) +} export function SeriesPage() { const { language, t } = useLanguage() useMetaTags({ title: t.common.series, description: t.series.description, url: localizePath("/series", language) }) + const [searchParams, setSearchParams] = useSearchParams() - const selectedSeries = searchParams.get("name") + const selectedSeriesName = searchParams.get("name") ?? "" + const query = searchParams.get("q") ?? "" + const sortMode = toSeriesSort(searchParams.get("sort")) + + const posts = useMemo(() => getAllPosts(language), [language]) + const allSeries = useMemo(() => getAllSeries(language), [language]) + const seriesByName = useMemo(() => new Map(allSeries.map((series) => [series.name, series])), [allSeries]) + const selectedSeries = selectedSeriesName ? seriesByName.get(selectedSeriesName) : undefined + + const matchingSeries = useMemo(() => { + const trimmedQuery = query.trim() + const filtered = trimmedQuery ? allSeries.filter((series) => matchesQuery(series, trimmedQuery)) : allSeries + return sortSeries(filtered, sortMode) + }, [allSeries, query, sortMode]) + + const activeSeries = selectedSeries ?? matchingSeries[0] ?? null + + const groupedSeries = useMemo(() => { + const queryValue = query.trim() + const groups = SERIES_GROUPS.map((group) => ({ ...group, series: [] as SeriesInfo[] })) + const ungrouped: SeriesInfo[] = [] + + for (const series of matchingSeries) { + const [bestGroup] = groups + .map((group, index) => ({ group, index, score: getGroupScore(series, group) })) + .sort((a, b) => b.score - a.score || a.index - b.index) + + if (bestGroup && bestGroup.score > 0) { + bestGroup.group.series.push(series) + } else { + ungrouped.push(series) + } + } + + const visibleGroups = groups.filter((group) => group.series.length > 0) + if (ungrouped.length > 0) { + visibleGroups.push({ + id: "etc", + label: t.series.ungrouped, + tags: [], + series: queryValue ? ungrouped : ungrouped.slice(0, 8), + }) + } + + return visibleGroups + }, [matchingSeries, query, t.series.ungrouped]) - const allSeries = getAllSeries(language) - const seriesPosts = selectedSeries ? getSeriesPosts(selectedSeries, language) : [] + function updateParam(key: string, value: string, defaultValue = "") { + const next = new URLSearchParams(searchParams) + if (!value || value === defaultValue) { + next.delete(key) + } else { + next.set(key, value) + } + setSearchParams(next, { replace: true }) + } + + function resetFilters() { + setSearchParams({}, { replace: true }) + } - function clearSeries() { - setSearchParams({}) + function seriesPathFor(seriesName: string) { + const next = new URLSearchParams(searchParams) + next.set("name", seriesName) + const suffix = next.toString() + return localizePath(`/series${suffix ? `?${suffix}` : ""}`, language) } + const hasFilters = Boolean(query.trim() || selectedSeriesName || sortMode !== "recent") + return ( - -

{t.common.series}

- - {!selectedSeries ? ( -
- {allSeries.map((series) => ( - +
+
+

{t.common.series}

+

+ {t.series.summary(posts.length, allSeries.length)} +

+
+ + {activeSeries && ( + + {t.series.selectedLabel(activeSeries.name)} + + )} +
+ +
+
+ + updateParam("q", event.target.value)} + placeholder={t.series.searchPlaceholder} + className="pl-9" + /> +
+ +
+ { if (value) updateParam("sort", value, "recent") }} + variant="outline" + size="sm" + > + {t.series.sortRecent} + {t.series.sortCount} + {t.series.sortName} + + + {hasFilters && ( + )}
+
+ + {allSeries.length === 0 ? ( +

{t.series.noSeries}

) : ( -
-
- - - {selectedSeries} - - - +
+
+
+
+

+ {t.series.topSeries} +

+ {t.series.topSeriesNote} +
+ + {matchingSeries.length === 0 ? ( +

{t.series.noMatchingSeries}

+ ) : ( +
+ {matchingSeries.map((series) => ( + + + + + {series.name} + + + {series.description || t.series.fallbackDescription(series.name)} + + + {t.series.postCountShort(series.postCount)} + + + ))} +
+ )} +
+ +
+

+ {t.series.topicGroups} +

+ + {groupedSeries.length === 0 ? ( +

{t.series.noMatchingSeries}

+ ) : ( +
+ {groupedSeries.map((group) => ( +
+

{group.label}

+
+ {group.series.map((series) => ( + + {series.name} + {t.series.postCountShort(series.postCount)} + + ))} +
+
+ ))} +
+ )} +
- + {activeSeries && ( + + )}
)}