From 62e024c0a741e83eea1c278027a83927b23960c5 Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Fri, 10 Apr 2026 09:54:35 +0300 Subject: [PATCH] feat: implement blog post detail page with markdown rendering, table of contents, and reading progress bar --- app/blog/page.tsx | 5 +- components/app-shell.tsx | 6 +- components/blog-content.tsx | 247 +++++++++++++++++++---------- components/blog-detail-content.tsx | 81 +++++++--- components/blog-toc.tsx | 30 ++-- data/blog.ts | 41 +++-- 6 files changed, 257 insertions(+), 153 deletions(-) diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 3c48bc3..6728d80 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -2,7 +2,7 @@ import { BlogContent } from "@/components/blog-content"; import { getBlogPageData } from "@/data/blog"; type BlogPageProps = { - searchParams?: Promise<{ page?: string | string[]; category?: string | string[] }>; + searchParams?: Promise<{ page?: string | string[]; category?: string | string[]; search?: string | string[] }>; }; export default async function BlogPage({ searchParams }: BlogPageProps) { @@ -11,9 +11,10 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { const categoryParam = Array.isArray(resolved?.category) ? resolved?.category[0] : resolved?.category; + const searchParam = Array.isArray(resolved?.search) ? resolved?.search[0] : resolved?.search; const page = Number(pageParam ?? "1"); const currentPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; - const data = await getBlogPageData(currentPage, 12, categoryParam); + const data = await getBlogPageData(currentPage, 12, categoryParam, searchParam); return ; } diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 60c1ba5..6dd8117 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { Icon } from "@iconify/react"; import Link from "next/link"; @@ -24,7 +24,7 @@ export function AppShell({ children }: AppShellProps) { return ( <> -
+
{announcement ? ( ) : null} -
{children}
+
{children}
); diff --git a/components/blog-content.tsx b/components/blog-content.tsx index 942f2a7..cf5de22 100644 --- a/components/blog-content.tsx +++ b/components/blog-content.tsx @@ -1,10 +1,12 @@ "use client"; +import { useState, useCallback } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Icon } from "@iconify/react"; import { Badge, Card, Typography } from "poyraz-ui/atoms"; import { ArticleCard, - NewsCard, Pagination, PaginationContent, PaginationItem, @@ -19,72 +21,116 @@ type BlogContentProps = { data: BlogPageData; }; -function pageHref(page: number, category: string) { - const params = new URLSearchParams(); +function buildHref(params: { page?: number; category?: string; search?: string }) { + const qs = new URLSearchParams(); - if (page > 1) { - params.set("page", String(page)); - } + if (params.page && params.page > 1) qs.set("page", String(params.page)); + if (params.category && params.category !== "All") qs.set("category", params.category); + if (params.search) qs.set("search", params.search); - if (category !== "All") { - params.set("category", category); - } - - const query = params.toString(); + const query = qs.toString(); return query ? `/blog?${query}` : "/blog"; } -function categoryHref(category: string) { - if (category === "All") { - return "/blog"; - } - - const params = new URLSearchParams(); - params.set("category", category); - - return `/blog?${params.toString()}`; -} - export function BlogContent({ data }: BlogContentProps) { - const pageNumbers = Array.from({ length: data.totalPages }, (_, index) => index + 1); + const router = useRouter(); + const [searchInput, setSearchInput] = useState(data.searchQuery); + const pageNumbers = Array.from({ length: data.totalPages }, (_, i) => i + 1); const hasArticles = data.articles.length > 0; + const submitSearch = useCallback( + (value: string) => { + const trimmed = value.trim(); + router.push(buildHref({ category: data.selectedCategory, search: trimmed })); + }, + [router, data.selectedCategory], + ); + + const clearFilters = useCallback(() => { + setSearchInput(""); + router.push("/blog"); + }, [router]); + return ( -
-
-
- {data.news.map((post) => ( - + {/* Filtre Çubuğu */} + +
+ {/* Kategoriler */} +
+ + Kategori: + + {data.categories.map((category) => ( + + + {category} + + + ))} +
+ + {/* Arama */} +
+ - ))} + setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submitSearch(searchInput); + }} + placeholder="Başlık veya içerikte ara..." + className="w-full rounded-sm border border-border bg-background py-2 pr-10 pl-9 text-sm text-foreground placeholder:text-muted-foreground focus:border-red-600 focus:ring-1 focus:ring-red-600/30 focus:outline-none" + /> + {searchInput && ( + + )} +
+
-
- - Kategoriler -
- {data.categories.map((category) => ( - - - {category} - - - ))} -
-
+ {/* Aktif filtre göstergesi */} + {(data.searchQuery || data.selectedCategory !== "All") && ( +
+ + {data.articles.length} sonuç + {data.searchQuery ? ` · "${data.searchQuery}"` : ""} + {data.selectedCategory !== "All" ? ` · ${data.selectedCategory}` : ""} + +
-
+ )} + {/* Yazı Kartları */}
{hasArticles ? ( @@ -98,50 +144,83 @@ export function BlogContent({ data }: BlogContentProps) { date={post.date} readTime={post.readTime} href={post.href} - className="rounded-sm border-border [&_h3]:line-clamp-2 [&_h3]:min-h-[2.5rem]" + className="rounded-sm border-border [&_h3]:line-clamp-2 [&_h3]:min-h-[2.5rem] [&_p]:line-clamp-3" author={{ name: post.author, avatar: "/logo/logo.jpeg" }} /> ))} ) : ( - + + - {data.selectedCategory === "All" - ? "Henüz blog yazısı bulunmuyor." - : `"${data.selectedCategory}" kategorisinde henüz blog yazısı bulunmuyor.`} + {data.searchQuery + ? `"${data.searchQuery}" aramasına uygun sonuç bulunamadı.` + : data.selectedCategory === "All" + ? "Henüz blog yazısı bulunmuyor." + : `"${data.selectedCategory}" kategorisinde henüz blog yazısı bulunmuyor.`} + {(data.searchQuery || data.selectedCategory !== "All") && ( + + )} )} - - - - - - - {pageNumbers.map((page) => ( - - - {page} - + {/* Sayfalama */} + {data.totalPages > 1 && ( + + + + - ))} - - = data.totalPages} - /> - - - + {pageNumbers.map((page) => ( + + + {page} + + + ))} + + + = data.totalPages} + /> + + + + )}
); diff --git a/components/blog-detail-content.tsx b/components/blog-detail-content.tsx index 9b0fa57..7fe7613 100644 --- a/components/blog-detail-content.tsx +++ b/components/blog-detail-content.tsx @@ -3,7 +3,8 @@ import Link from "next/link"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Icon } from "@iconify/react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; import { Badge, Card, Typography } from "poyraz-ui/atoms"; @@ -99,38 +100,37 @@ const GISCUS_CATEGORY = process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "Announcement const GISCUS_CATEGORY_ID = process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || ""; export function BlogDetailContent({ post }: BlogDetailContentProps) { - const scrollerRef = useRef(null); - const [progress, setProgress] = useState(0); - const progressWidth = useMemo(() => `${Math.min(100, Math.max(0, progress))}%`, [progress]); + const progressBarRef = useRef(null); + const [tocOpen, setTocOpen] = useState(false); - const handleScroll = () => { - const element = scrollerRef.current; - if (!element) return; + const closeToc = useCallback(() => setTocOpen(false), []); - const scrollable = element.scrollHeight - element.clientHeight; - if (scrollable <= 0) { - setProgress(100); - return; - } + useEffect(() => { + const update = () => { + const bar = progressBarRef.current; + if (!bar) return; + const docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; + const pct = docHeight <= 0 ? 100 : (window.scrollY / docHeight) * 100; + bar.style.width = `${Math.min(100, Math.max(0, pct))}%`; + }; - setProgress((element.scrollTop / scrollable) * 100); - }; + window.addEventListener("scroll", update, { passive: true }); + update(); + + return () => window.removeEventListener("scroll", update); + }, []); const showGiscus = GISCUS_REPO && GISCUS_REPO_ID && GISCUS_CATEGORY_ID; return ( <>
-
+
-
-
-
+
+
+
+ + {/* Mobil İçindekiler Butonu */} + + + {/* Mobil İçindekiler Drawer */} + {tocOpen && ( +
+
+
e.stopPropagation()} + > +
+ İçindekiler + +
+ +
+
+ )} ); } diff --git a/components/blog-toc.tsx b/components/blog-toc.tsx index 82068bc..34124b0 100644 --- a/components/blog-toc.tsx +++ b/components/blog-toc.tsx @@ -36,30 +36,24 @@ function parseHeadings(markdown: string): TocHeading[] { type BlogTocProps = { markdown: string; - scrollerRef: React.RefObject; + onNavigate?: () => void; }; -export function BlogToc({ markdown, scrollerRef }: BlogTocProps) { +export function BlogToc({ markdown, onNavigate }: BlogTocProps) { const headings = useMemo(() => parseHeadings(markdown), [markdown]); const [activeId, setActiveId] = useState(""); const observerRef = useRef(null); - const handleClick = useCallback( - (id: string) => { - const scroller = scrollerRef.current; - if (!scroller) return; + const handleClick = useCallback((id: string) => { + const target = document.getElementById(id); + if (!target) return; - const target = scroller.querySelector(`#${CSS.escape(id)}`); - if (!target) return; - - target.scrollIntoView({ behavior: "smooth", block: "start" }); - }, - [scrollerRef], - ); + target.scrollIntoView({ behavior: "smooth", block: "start" }); + onNavigate?.(); + }, [onNavigate]); useEffect(() => { - const scroller = scrollerRef.current; - if (!scroller || headings.length === 0) return; + if (headings.length === 0) return; observerRef.current = new IntersectionObserver( (entries) => { @@ -69,11 +63,11 @@ export function BlogToc({ markdown, scrollerRef }: BlogTocProps) { } } }, - { root: scroller, rootMargin: "0px 0px -60% 0px", threshold: 0.1 }, + { root: null, rootMargin: "0px 0px -60% 0px", threshold: 0.1 }, ); const elements = headings - .map((h) => scroller.querySelector(`#${CSS.escape(h.id)}`)) + .map((h) => document.getElementById(h.id)) .filter(Boolean) as Element[]; for (const el of elements) { @@ -83,7 +77,7 @@ export function BlogToc({ markdown, scrollerRef }: BlogTocProps) { return () => { observerRef.current?.disconnect(); }; - }, [headings, scrollerRef]); + }, [headings]); if (headings.length < 2) return null; diff --git a/data/blog.ts b/data/blog.ts index a387b00..7310f6b 100644 --- a/data/blog.ts +++ b/data/blog.ts @@ -2,15 +2,6 @@ import "server-only"; import { listBlogDetails } from "@/data/blog-detail"; -export type BlogNewsItem = { - id: string; - title: string; - category: string; - image: string; - date: string; - href: string; -}; - export type BlogArticleItem = { id: string; slug: string; @@ -25,10 +16,10 @@ export type BlogArticleItem = { }; export type BlogPageData = { - news: BlogNewsItem[]; articles: BlogArticleItem[]; categories: string[]; selectedCategory: string; + searchQuery: string; totalPages: number; currentPage: number; }; @@ -82,7 +73,7 @@ export async function getAllBlogArticles(): Promise { return sortByDateDesc(articles); } -export async function getHomeBlogNews(limit = 3): Promise { +export async function getHomeBlogNews(limit = 3) { const articles = await getAllBlogArticles(); return articles.slice(0, limit).map((item) => ({ @@ -99,6 +90,7 @@ export async function getBlogPageData( page = 1, pageSize = 12, selectedCategoryParam?: string, + searchQueryParam?: string, ): Promise { const articles = await getAllBlogArticles(); const categories = BLOG_CATEGORIES; @@ -108,27 +100,32 @@ export async function getBlogPageData( const requestedCategory = selectedCategoryParam?.trim(); const selectedCategory = (requestedCategory && categoryByNormalized.get(normalizeCategory(requestedCategory))) || "All"; - const filteredArticles = + const searchQuery = (searchQueryParam ?? "").trim(); + const searchLower = searchQuery.toLocaleLowerCase(); + + let filtered = selectedCategory === "All" ? articles : articles.filter((item) => normalizeCategory(item.category) === normalizeCategory(selectedCategory)); - const totalPages = Math.max(1, Math.ceil(Math.max(filteredArticles.length, 1) / pageSize)); + + if (searchLower) { + filtered = filtered.filter( + (item) => + item.title.toLocaleLowerCase().includes(searchLower) || + item.excerpt.toLocaleLowerCase().includes(searchLower), + ); + } + + const totalPages = Math.max(1, Math.ceil(Math.max(filtered.length, 1) / pageSize)); const currentPage = Math.min(Math.max(1, page), totalPages); const start = (currentPage - 1) * pageSize; - const paginated = filteredArticles.slice(start, start + pageSize); + const paginated = filtered.slice(start, start + pageSize); return { - news: articles.slice(0, 4).map((item) => ({ - id: `blog-news-${item.slug}`, - title: item.title, - category: item.category, - image: item.image, - date: item.date, - href: item.href, - })), articles: paginated, categories, selectedCategory, + searchQuery, totalPages, currentPage, };