From 8436a8067743a6a3b77276d79a13450dabd987e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Mon, 22 Jun 2026 18:23:04 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(posts):=20=EA=B8=80=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=97=90=20=EA=B2=80=EC=83=89=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- src/components/Sidebar.tsx | 4 +- src/entry-server.tsx | 10 +- src/i18n/translations.ts | 57 +++++++++ src/pages/PostsPage.tsx | 238 +++++++++++++++++++++++++++++++++++-- src/pages/SearchPage.tsx | 103 ++++------------ vite.config.ts | 2 +- 7 files changed, 312 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index e126014..3333b90 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,10 @@ flowchart LR | Path | Page | Description | | --- | --- | --- | | `/` | Home | 최신글, 인기글, 블로그 통계 | -| `/posts` | Posts | 전체 글 목록, 리스트/그리드 뷰 | +| `/posts` | Posts | 전체 글 목록, 검색, 정렬, 태그/연도 필터 | | `/posts/:slug` | Post | Markdown 글 상세, TOC, 댓글 | | `/tags` | Tags | 태그별 글 탐색 | | `/series` | Series | 시리즈별 글 탐색 | -| `/search` | Search | 키워드, 날짜, 태그 기반 고급 검색 | | `/analytics` | Analytics | 방문자 및 조회수 대시보드 | | `/about` | About | 소개, 경력, 프로젝트 요약 | | `/about/projects/:slug` | Project Detail | 프로젝트 상세 | diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 2257a1b..c1d6dcb 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { NavLink, useLocation, useNavigate } from "react-router-dom" -import { Home, FileText, Tags, User, Search, Library, BarChart3 } from "lucide-react" +import { Home, FileText, Tags, User, Library, BarChart3 } from "lucide-react" import { Sidebar as SidebarRoot, SidebarContent, @@ -25,7 +25,6 @@ const navIcons = { home: Home, posts: FileText, series: Library, - search: Search, tags: Tags, analytics: BarChart3, about: User, @@ -50,7 +49,6 @@ export function AppSidebar() { { label: t.common.home, to: localizePath("/", language), basePath: "/", icon: navIcons.home }, { label: t.common.posts, to: localizePath("/posts", language), basePath: "/posts", icon: navIcons.posts }, { label: t.common.series, to: localizePath("/series", language), basePath: "/series", icon: navIcons.series }, - { label: t.common.search, to: localizePath("/search", language), basePath: "/search", icon: navIcons.search }, { label: t.common.tags, to: localizePath("/tags", language), basePath: "/tags", icon: navIcons.tags }, { label: t.common.analytics, to: localizePath("/analytics", language), basePath: "/analytics", icon: navIcons.analytics }, { label: t.common.about, to: localizePath("/about", language), basePath: "/about", icon: navIcons.about }, diff --git a/src/entry-server.tsx b/src/entry-server.tsx index 594dad6..5fb2dd0 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -136,11 +136,13 @@ function localizedStaticRoutes(language: Language, posts: PostMeta[]): Prerender { path: path("/search/"), language, - title: isEnglish ? "Search" : "검색", - description: isEnglish ? "Search blog posts by keyword, tag, or date." : "블로그 글을 키워드, 태그, 날짜로 검색합니다.", + title: isEnglish ? "Posts" : "글 목록", + description: postsDescription, + noindex: true, + canonicalPath: path("/posts/"), alternates: { - ko: "/search/", - en: "/en/search/", + ko: "/posts/", + en: "/en/posts/", }, }, { diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index f2ebea5..0c2538d 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -44,6 +44,22 @@ export interface Translations { description: string listView: string gridView: string + searchPlaceholder: string + sortLabel: string + yearLabel: string + scopeLabel: string + sortLatest: string + sortPopular: string + sortOldest: string + allYears: string + allTags: string + scopeAll: string + scopeSummary: string + scopeTags: string + reset: string + totalCount: (count: number) => string + filteredCount: (count: number) => string + noResults: string } search: { description: string @@ -55,6 +71,9 @@ export interface Translations { reset: string noResults: string searchGuide: string + redirectTitle: string + redirectDescription: string + goToPosts: string } series: { description: string @@ -184,6 +203,22 @@ export const ko: Translations = { description: "전체 블로그 글 목록", listView: "리스트 보기", gridView: "그리드 보기", + searchPlaceholder: "제목, 설명, 태그, 본문 검색", + sortLabel: "정렬", + yearLabel: "연도", + scopeLabel: "검색 범위", + sortLatest: "최신순", + sortPopular: "인기순", + sortOldest: "오래된순", + allYears: "전체 연도", + allTags: "전체", + scopeAll: "제목, 설명, 태그, 본문", + scopeSummary: "제목, 설명, 태그", + scopeTags: "태그만", + reset: "초기화", + totalCount: (count) => `${count}개의 글을 최신순으로 보고 있습니다.`, + filteredCount: (count) => `${count}개의 글을 찾았습니다.`, + noResults: "조건에 맞는 글이 없습니다.", }, search: { description: "블로그 글 검색", @@ -195,6 +230,9 @@ export const ko: Translations = { reset: "초기화", noResults: "검색 결과가 없습니다.", searchGuide: "키워드, 날짜, 태그를 선택하여 검색하세요.", + redirectTitle: "검색이 글 목록으로 통합되었습니다", + redirectDescription: "글 검색과 필터는 이제 글 목록에서 함께 사용할 수 있습니다.", + goToPosts: "글 목록으로 이동", }, series: { description: "시리즈별 블로그 글 목록", @@ -324,6 +362,22 @@ export const en: Translations = { description: "All blog posts", listView: "List view", gridView: "Grid view", + searchPlaceholder: "Search title, description, tags, or content", + sortLabel: "Sort", + yearLabel: "Year", + scopeLabel: "Search scope", + sortLatest: "Latest", + sortPopular: "Popular", + sortOldest: "Oldest", + allYears: "All years", + allTags: "All", + scopeAll: "Title, description, tags, content", + scopeSummary: "Title, description, tags", + scopeTags: "Tags only", + reset: "Reset", + totalCount: (count) => `Showing ${count} post${count !== 1 ? "s" : ""} by latest date.`, + filteredCount: (count) => `Found ${count} post${count !== 1 ? "s" : ""}.`, + noResults: "No posts match these filters.", }, search: { description: "Search blog posts", @@ -335,6 +389,9 @@ export const en: Translations = { reset: "Reset", noResults: "No results found.", searchGuide: "Search by keyword, date, or tag.", + redirectTitle: "Search moved to Posts", + redirectDescription: "Post search and filters are now available directly in the posts list.", + goToPosts: "Go to posts", }, series: { description: "Blog posts by series", diff --git a/src/pages/PostsPage.tsx b/src/pages/PostsPage.tsx index 850cb59..617a230 100644 --- a/src/pages/PostsPage.tsx +++ b/src/pages/PostsPage.tsx @@ -1,23 +1,135 @@ -import { useState, useMemo } from "react" +import { useMemo, useState } from "react" +import { useSearchParams } from "react-router-dom" import { useMetaTags } from "@/hooks/useMetaTags" -import { LayoutListIcon, LayoutGridIcon } from "lucide-react" -import { getAllPosts } from "@/lib/posts" +import { LayoutListIcon, LayoutGridIcon, SearchIcon, XIcon } from "lucide-react" +import { getAllPosts, searchPosts } from "@/lib/posts" import { PostList } from "@/components/PostList" import { PageContainer } from "@/components/PageContainer" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { usePageViews } from "@/hooks/usePageViews" import { useLanguage } from "@/i18n" import { localizePath } from "@/lib/i18n-routing" import type { PostMeta } from "@/types/post" +type SortMode = "latest" | "popular" | "oldest" +type SearchScope = "all" | "summary" | "tags" + +const sortModes = new Set(["latest", "popular", "oldest"]) +const searchScopes = new Set(["all", "summary", "tags"]) + +function toSortMode(value: string | null): SortMode { + return sortModes.has(value as SortMode) ? (value as SortMode) : "latest" +} + +function toSearchScope(value: string | null): SearchScope { + return searchScopes.has(value as SearchScope) ? (value as SearchScope) : "all" +} + +function postMatchesSummary(post: PostMeta, query: string) { + const q = query.toLowerCase() + return ( + post.title.toLowerCase().includes(q) || + post.description.toLowerCase().includes(q) || + post.tags.some((tag) => tag.toLowerCase().includes(q)) + ) +} + +function postMatchesTags(post: PostMeta, query: string) { + const q = query.toLowerCase() + return post.tags.some((tag) => tag.toLowerCase().includes(q)) +} + export function PostsPage() { const { language, t } = useLanguage() useMetaTags({ title: t.common.posts, description: t.posts.description, url: localizePath("/posts", language) }) - const posts = getAllPosts(language) + const [searchParams, setSearchParams] = useSearchParams() + const { getPostViews } = usePageViews() + const allPosts = useMemo(() => getAllPosts(language), [language]) const [viewMode, setViewMode] = useState<"list" | "grid">("list") + const query = searchParams.get("q") ?? "" + const selectedTag = searchParams.get("tag") ?? "" + const selectedYear = searchParams.get("year") ?? "" + const sortMode = toSortMode(searchParams.get("sort")) + const searchScope = toSearchScope(searchParams.get("scope")) + + 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 }) + } + + const availableYears = useMemo(() => ( + Array.from(new Set(allPosts.map((post) => post.date.slice(0, 4)))).sort((a, b) => (a > b ? -1 : 1)) + ), [allPosts]) + + const featuredTags = useMemo(() => { + const tagCounts = new Map() + for (const post of allPosts) { + for (const tag of post.tags) { + tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1) + } + } + + const tags = [...tagCounts.entries()] + .sort(([a, aCount], [b, bCount]) => bCount - aCount || a.localeCompare(b)) + .slice(0, 7) + .map(([tag]) => tag) + + if (selectedTag && !tags.includes(selectedTag)) return [selectedTag, ...tags] + return tags + }, [allPosts, selectedTag]) + + const filteredPosts = useMemo(() => { + const trimmedQuery = query.trim() + let result = trimmedQuery + ? searchScope === "all" + ? searchPosts(trimmedQuery, language) + : allPosts.filter((post) => ( + searchScope === "summary" + ? postMatchesSummary(post, trimmedQuery) + : postMatchesTags(post, trimmedQuery) + )) + : allPosts + + if (selectedTag) { + result = result.filter((post) => post.tags.includes(selectedTag)) + } + if (selectedYear) { + result = result.filter((post) => post.date.startsWith(selectedYear)) + } + + return [...result].sort((a, b) => { + if (sortMode === "oldest") return a.date > b.date ? 1 : -1 + if (sortMode === "popular") { + const aViews = getPostViews(a.slug, a.language) ?? -1 + const bViews = getPostViews(b.slug, b.language) ?? -1 + if (aViews !== bViews) return bViews - aViews + } + return a.date > b.date ? -1 : 1 + }) + }, [allPosts, getPostViews, language, query, searchScope, selectedTag, selectedYear, sortMode]) + const groupedByYear = useMemo(() => { const groups = new Map() - for (const post of posts) { + for (const post of filteredPosts) { const year = post.date.slice(0, 4) const list = groups.get(year) if (list) { @@ -27,7 +139,9 @@ export function PostsPage() { } } return groups - }, [posts]) + }, [filteredPosts]) + + const hasFilters = Boolean(query.trim() || selectedTag || selectedYear || sortMode !== "latest" || searchScope !== "all") return ( @@ -48,14 +162,112 @@ export function PostsPage() { - {[...groupedByYear.entries()].map(([year, yearPosts]) => ( -
-

- {year} -

- +
+
+ + updateParam("q", event.target.value)} + placeholder={t.posts.searchPlaceholder} + className="pl-9" + />
- ))} + +
+
+ + + + + +
+ + {hasFilters && ( + + )} +
+ +
+ + {featuredTags.map((tag) => ( + + ))} +
+ +

+ {hasFilters ? t.posts.filteredCount(filteredPosts.length) : t.posts.totalCount(allPosts.length)} +

+
+ + {filteredPosts.length === 0 ? ( + + ) : ( + [...groupedByYear.entries()].map(([year, yearPosts]) => ( +
+

+ {year} +

+ +
+ )) + )} ) } diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index c2e13e6..187d0e8 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,99 +1,38 @@ -import { useState } from "react" -import { SearchIcon, XIcon } from "lucide-react" -import { advancedSearch, getAllTags } from "@/lib/posts" -import { PostList } from "@/components/PostList" +import { useEffect } from "react" +import { Link, useLocation, useNavigate } from "react-router-dom" import { PageContainer } from "@/components/PageContainer" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { DatePicker } from "@/components/DatePicker" import { useMetaTags } from "@/hooks/useMetaTags" import { useLanguage } from "@/i18n" import { localizePath } from "@/lib/i18n-routing" export function SearchPage() { const { language, t } = useLanguage() - useMetaTags({ title: t.common.search, description: t.search.description, url: localizePath("/search", language) }) + const location = useLocation() + const navigate = useNavigate() + const postsPath = localizePath(`/posts${location.search}`, language) - const [query, setQuery] = useState("") - const [dateFrom, setDateFrom] = useState("") - const [dateTo, setDateTo] = useState("") - const [selectedTag, setSelectedTag] = useState("") + useMetaTags({ + title: t.common.posts, + description: t.posts.description, + url: localizePath("/posts", language), + }) - const allTags = getAllTags(language) - const hasFilters = query || dateFrom || dateTo || selectedTag - - const results = hasFilters - ? advancedSearch({ query, dateFrom, dateTo, tag: selectedTag, language }) - : [] - - function clearFilters() { - setQuery("") - setDateFrom("") - setDateTo("") - setSelectedTag("") - } + useEffect(() => { + navigate(postsPath, { replace: true }) + }, [navigate, postsPath]) return ( -

{t.common.search}

- -
-
- - setQuery(e.target.value)} - className="pl-9" - /> -
- -
-
- - -
-
- - -
-
- -
- -
- {allTags.map((tag) => ( - setSelectedTag(selectedTag === tag ? "" : tag)} - > - {tag} - - ))} -
-
- - {hasFilters && ( -
- - {t.search.resultCount(results.length)} - - -
- )} +
+

{t.search.redirectTitle}

+

{t.search.redirectDescription}

+
- - {hasFilters ? ( - - ) : ( -

{t.search.searchGuide}

- )} ) } diff --git a/vite.config.ts b/vite.config.ts index 854ca77..542c3b9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -120,7 +120,7 @@ function sitemapPlugin(): Plugin { const koPosts = readPosts("ko") const enPosts = readPosts("en") const enPostSlugs = new Set(enPosts.map((p) => p.slug)) - const staticPages = ["/", "/posts/", "/tags/", "/series/", "/search/", "/analytics/", "/about/"] + const staticPages = ["/", "/posts/", "/tags/", "/series/", "/analytics/", "/about/"] const today = new Date().toISOString().split("T")[0] function alternateEntry(hreflang: string, href: string) { From 47a4c89ecd462156e02a1386d20be7610eff270e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Mon, 22 Jun 2026 18:39:04 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(tags):=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=ED=97=88=EB=B8=8C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- src/i18n/translations.ts | 51 +++++ src/pages/TagsPage.tsx | 411 +++++++++++++++++++++++++++++++++++---- 3 files changed, 422 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 3333b90..fcc0487 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ flowchart LR | `/` | Home | 최신글, 인기글, 블로그 통계 | | `/posts` | Posts | 전체 글 목록, 검색, 정렬, 태그/연도 필터 | | `/posts/:slug` | Post | Markdown 글 상세, TOC, 댓글 | -| `/tags` | Tags | 태그별 글 탐색 | +| `/tags` | Tags | 태그별 주제 탐색, 관련 태그, 최근 글 | | `/series` | Series | 시리즈별 글 탐색 | | `/analytics` | Analytics | 방문자 및 조회수 대시보드 | | `/about` | About | 소개, 경력, 프로젝트 요약 | diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 0c2538d..e26493a 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -84,6 +84,23 @@ export interface Translations { } tags: { description: string + summary: (postCount: number, tagCount: number) => string + searchPlaceholder: string + sortPopular: string + sortRecent: string + sortName: string + reset: string + selectedLabel: (tag: string) => string + topTags: string + topTagsNote: string + topicGroups: string + ungrouped: string + postCount: (count: number) => string + relatedTags: string + recentPosts: string + selectedDescription: (tag: string) => string + viewInPosts: (tag: string) => string + noMatchingTags: string noTags: string allTags: string noPostsWithTag: string @@ -243,6 +260,23 @@ export const ko: Translations = { }, tags: { description: "태그별 블로그 글 목록", + summary: (postCount, tagCount) => `${postCount}개의 글이 ${tagCount}개의 태그로 정리되어 있습니다.`, + searchPlaceholder: "태그 이름 검색", + sortPopular: "많은 글순", + sortRecent: "최근 글순", + sortName: "가나다순", + reset: "초기화", + selectedLabel: (tag) => `선택됨 · ${tag}`, + topTags: "주요 태그", + topTagsNote: "글 수와 최근 발행 기준", + topicGroups: "주제 그룹", + ungrouped: "기타", + postCount: (count) => `${count}개 글`, + relatedTags: "관련 태그", + recentPosts: "최근 글", + selectedDescription: (tag) => `${tag} 태그가 붙은 글의 흐름과 함께 자주 등장하는 주제를 모아봅니다.`, + viewInPosts: (tag) => `${tag} 글 목록으로 보기`, + noMatchingTags: "조건에 맞는 태그가 없습니다.", noTags: "태그가 없습니다.", allTags: "전체 태그", noPostsWithTag: "해당 태그의 글이 없습니다.", @@ -402,6 +436,23 @@ export const en: Translations = { }, tags: { description: "Blog posts by tag", + summary: (postCount, tagCount) => `${postCount} post${postCount !== 1 ? "s" : ""} are organized with ${tagCount} tag${tagCount !== 1 ? "s" : ""}.`, + searchPlaceholder: "Search tags", + sortPopular: "Most used", + sortRecent: "Recent", + sortName: "A-Z", + reset: "Reset", + selectedLabel: (tag) => `Selected · ${tag}`, + topTags: "Top Tags", + topTagsNote: "By post count and latest publish date", + topicGroups: "Topic Groups", + ungrouped: "Other", + postCount: (count) => `${count} post${count !== 1 ? "s" : ""}`, + relatedTags: "Related Tags", + recentPosts: "Recent Posts", + selectedDescription: (tag) => `Explore posts tagged with ${tag} and the topics that often appear with it.`, + viewInPosts: (tag) => `View ${tag} posts`, + noMatchingTags: "No tags match these filters.", noTags: "No tags yet.", allTags: "All tags", noPostsWithTag: "No posts with this tag.", diff --git a/src/pages/TagsPage.tsx b/src/pages/TagsPage.tsx index b6de41c..97f9eed 100644 --- a/src/pages/TagsPage.tsx +++ b/src/pages/TagsPage.tsx @@ -1,67 +1,396 @@ +import { useMemo } from "react" import { Link, useSearchParams } from "react-router-dom" +import { SearchIcon, XIcon } from "lucide-react" import { useMetaTags } from "@/hooks/useMetaTags" import { getAllPosts } from "@/lib/posts" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -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" +import type { PostMeta } from "@/types/post" + +type TagSort = "popular" | "recent" | "name" + +interface TagStats { + tag: string + count: number + latestDate: string + posts: PostMeta[] +} + +interface TagGroup { + id: string + label: string + tags: string[] +} + +const tagSorts = new Set(["popular", "recent", "name"]) + +const TAG_GROUPS: TagGroup[] = [ + { + id: "backend", + label: "Backend", + tags: ["java", "spring-boot", "spring", "authentication", "oauth", "jwt", "architecture", "payment"], + }, + { + id: "infra", + label: "Infra / DevOps", + tags: ["devops", "kubernetes", "migration", "aws", "gcp", "observability", "argocd", "gitops"], + }, + { + id: "frontend", + label: "Frontend", + tags: ["react", "typescript", "shadcn-ui", "css", "tailwind", "seo", "ux"], + }, + { + id: "ai-blog", + label: "AI / Blog", + tags: ["ai", "spring-ai", "llm", "prompt-engineering", "blog", "google-analytics", "view-transitions"], + }, +] + +function toTagSort(value: string | null): TagSort { + return tagSorts.has(value as TagSort) ? (value as TagSort) : "popular" +} + +function buildTagStats(posts: PostMeta[]): TagStats[] { + const tagMap = new Map() + + for (const post of posts) { + for (const tag of post.tags) { + const current = tagMap.get(tag) + if (current) { + current.count += 1 + current.latestDate = post.date > current.latestDate ? post.date : current.latestDate + current.posts.push(post) + } else { + tagMap.set(tag, { + tag, + count: 1, + latestDate: post.date, + posts: [post], + }) + } + } + } + + return [...tagMap.values()].map((stats) => ({ + ...stats, + posts: [...stats.posts].sort((a, b) => (a.date > b.date ? -1 : 1)), + })) +} + +function sortTagStats(tags: TagStats[], sortMode: TagSort) { + return [...tags].sort((a, b) => { + if (sortMode === "name") return a.tag.localeCompare(b.tag) + if (sortMode === "recent") { + if (a.latestDate !== b.latestDate) return b.latestDate.localeCompare(a.latestDate) + if (a.count !== b.count) return b.count - a.count + return a.tag.localeCompare(b.tag) + } + + if (a.count !== b.count) return b.count - a.count + if (a.latestDate !== b.latestDate) return b.latestDate.localeCompare(a.latestDate) + return a.tag.localeCompare(b.tag) + }) +} + +function matchesQuery(stats: TagStats, query: string) { + return stats.tag.toLowerCase().includes(query.toLowerCase()) +} export function TagsPage() { const { language, t } = useLanguage() useMetaTags({ title: t.common.tags, description: t.tags.description, url: localizePath("/tags", language) }) - const posts = getAllPosts(language) + + const posts = useMemo(() => getAllPosts(language), [language]) const [searchParams, setSearchParams] = useSearchParams() - const selectedTag = searchParams.get("tag") + const selectedTag = searchParams.get("tag") ?? "" + const query = searchParams.get("q") ?? "" + const sortMode = toTagSort(searchParams.get("sort")) + + const tagStats = useMemo(() => buildTagStats(posts), [posts]) + const statsByTag = useMemo(() => new Map(tagStats.map((stats) => [stats.tag, stats])), [tagStats]) + const selectedStats = selectedTag ? statsByTag.get(selectedTag) : undefined + + const matchingTags = useMemo(() => { + const trimmedQuery = query.trim() + const filtered = trimmedQuery ? tagStats.filter((stats) => matchesQuery(stats, trimmedQuery)) : tagStats + return sortTagStats(filtered, sortMode) + }, [query, sortMode, tagStats]) + + const visibleTopTags = query.trim() ? matchingTags : matchingTags.slice(0, 12) + const activeStats = selectedStats ?? matchingTags[0] ?? null + + const groupedTags = useMemo(() => { + const groupedTagNames = new Set(TAG_GROUPS.flatMap((group) => group.tags)) + const queryValue = query.trim() + + const groups = TAG_GROUPS.map((group) => ({ + ...group, + tags: sortTagStats( + group.tags + .map((tag) => statsByTag.get(tag)) + .filter((stats): stats is TagStats => Boolean(stats)) + .filter((stats) => !queryValue || matchesQuery(stats, queryValue)), + sortMode + ), + })).filter((group) => group.tags.length > 0) + + const ungrouped = sortTagStats( + tagStats + .filter((stats) => !groupedTagNames.has(stats.tag)) + .filter((stats) => !queryValue || matchesQuery(stats, queryValue)), + sortMode + ) + + if (ungrouped.length > 0) { + groups.push({ + id: "etc", + label: t.tags.ungrouped, + tags: queryValue ? ungrouped : ungrouped.slice(0, 10), + }) + } + + return groups + }, [query, sortMode, statsByTag, t.tags.ungrouped, tagStats]) - const allTags = Array.from(new Set(posts.flatMap((p) => p.tags))).sort() + const relatedTags = useMemo(() => { + if (!activeStats) return [] - const filteredPosts = selectedTag - ? posts.filter((p) => p.tags.includes(selectedTag)) - : [] + const related = new Map() + for (const post of activeStats.posts) { + for (const tag of post.tags) { + if (tag === activeStats.tag) continue + related.set(tag, (related.get(tag) ?? 0) + 1) + } + } - function clearTag() { - setSearchParams({}) + return [...related.entries()] + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)) + .slice(0, 6) + }, [activeStats]) + + 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 tagsPathFor(tag: string) { + const next = new URLSearchParams(searchParams) + next.set("tag", tag) + const suffix = next.toString() + return localizePath(`/tags${suffix ? `?${suffix}` : ""}`, language) + } + + const hasFilters = Boolean(query.trim() || selectedTag || sortMode !== "popular") + return ( - -

{t.common.tags}

- - {!selectedTag ? ( -
- {allTags.map((tag) => ( - - - {tag} - - - ))} - {allTags.length === 0 && ( -

{t.tags.noTags}

+ +
+
+

{t.common.tags}

+

+ {t.tags.summary(posts.length, tagStats.length)} +

+
+ + {selectedStats && ( + + {t.tags.selectedLabel(selectedStats.tag)} + + )} +
+ +
+
+ + updateParam("q", event.target.value)} + placeholder={t.tags.searchPlaceholder} + className="pl-9" + /> +
+ +
+ { if (value) updateParam("sort", value, "popular") }} + variant="outline" + size="sm" + > + {t.tags.sortPopular} + {t.tags.sortRecent} + {t.tags.sortName} + + + {hasFilters && ( + )}
+
+ + {tagStats.length === 0 ? ( +

{t.tags.noTags}

) : ( -
-
- - - {selectedTag} - - - +
+
+
+
+

+ {t.tags.topTags} +

+ {t.tags.topTagsNote} +
+ + {visibleTopTags.length === 0 ? ( +

{t.tags.noMatchingTags}

+ ) : ( +
+ {visibleTopTags.map((stats) => ( + + + + {stats.tag} + + {t.tags.postCount(stats.count)} + + + ))} +
+ )} +
+ +
+

+ {t.tags.topicGroups} +

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

{t.tags.noMatchingTags}

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

{group.label}

+
+ {group.tags.map((stats) => ( + + {stats.tag} + {t.tags.postCount(stats.count)} + + ))} +
+
+ ))} +
+ )} +
- + {activeStats && ( + + )}
)}