From 060917007328e5e26272bdb4dd78339b8a6aba7e Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Wed, 15 Apr 2026 10:38:28 +0300 Subject: [PATCH] feat: enhance components with new features and optimizations - Added support for conditional rendering of the NekoFollower component in AppShell based on ENABLE_NEKO_FOLLOWER. - Introduced MarkdownImage component in BlogDetailContent for handling images in markdown, supporting both local and external assets. - Improved BlogToc component to use IntersectionObserver for better performance in tracking active headings. - Refactored ContentContent to utilize YoutubeLiteEmbed for embedding YouTube videos, simplifying the video rendering logic. - Created a new YoutubeLiteEmbed component for lazy loading YouTube videos with thumbnail previews. - Updated site-settings to include ENABLE_NEKO_FOLLOWER constant for feature toggling. - Enhanced next.config.ts with image optimization settings for better performance. - Refactored snippets-content to improve code readability and structure. - Cleaned up home-hero and home-videos-section components for better layout and performance. --- components/app-shell.tsx | 7 +- components/blog-detail-content.tsx | 190 ++++++++++++++++++++++------- components/blog-toc.tsx | 74 +++++++---- components/content-content.tsx | 84 +++++++------ components/home-hero.tsx | 63 +++++----- components/home-videos-section.tsx | 39 ++---- components/site-navbar.tsx | 42 +++++-- components/snippets-content.tsx | 56 +++++---- components/youtube-lite-embed.tsx | 81 ++++++++++++ data/site-settings.ts | 2 + next.config.ts | 7 ++ 11 files changed, 438 insertions(+), 207 deletions(-) create mode 100644 components/youtube-lite-embed.tsx diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 6dd8117..9eb8308 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -6,7 +6,7 @@ import { usePathname } from "next/navigation"; import { AnnouncementBar } from "poyraz-ui/organisms"; import { SiteNavbar } from "@/components/site-navbar"; import { NekoFollower } from "@/components/neko-follower"; -import { ANNOUNCEMENT_ITEMS } from "@/data/site-settings"; +import { ANNOUNCEMENT_ITEMS, ENABLE_NEKO_FOLLOWER } from "@/data/site-settings"; type AppShellProps = { children: React.ReactNode; @@ -15,7 +15,8 @@ type AppShellProps = { export function AppShell({ children }: AppShellProps) { const pathname = usePathname(); const announcement = ANNOUNCEMENT_ITEMS[0]; - const isStandaloneLinksPage = pathname === "/links" || pathname.startsWith("/links/"); + const isStandaloneLinksPage = + pathname === "/links" || pathname.startsWith("/links/"); if (isStandaloneLinksPage) { return children; @@ -23,7 +24,7 @@ export function AppShell({ children }: AppShellProps) { return ( <> - + {ENABLE_NEKO_FOLLOWER ? : null}
{announcement ? ( diff --git a/components/blog-detail-content.tsx b/components/blog-detail-content.tsx index 14704a9..94697c5 100644 --- a/components/blog-detail-content.tsx +++ b/components/blog-detail-content.tsx @@ -1,12 +1,11 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; 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"; import type { BlogDetail } from "@/data/blog-detail"; import { BlogToc } from "@/components/blog-toc"; @@ -16,6 +15,59 @@ type BlogDetailContentProps = { post: BlogDetail; }; +function isHttpUrl(value: string) { + return value.startsWith("http://") || value.startsWith("https://"); +} + +function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { + if (!src) return null; + + const caption = alt || ""; + const isLocalAsset = src.startsWith("/"); + const isExternalAsset = isHttpUrl(src); + + if (!isLocalAsset && !isExternalAsset) return null; + + return ( + + {isLocalAsset ? ( + {caption} + ) : ( + {caption} + )} + + {caption ? ( +
+ + {caption} + +
+ ) : null} +
+ ); +} + function slugify(text: string) { return text .toLowerCase() @@ -31,7 +83,10 @@ function extractText(children: React.ReactNode): string { if (typeof children === "string") return children; if (Array.isArray(children)) return children.map(extractText).join(""); if (children && typeof children === "object" && "props" in children) { - return extractText((children as React.ReactElement<{ children?: React.ReactNode }>).props.children); + return extractText( + (children as React.ReactElement<{ children?: React.ReactNode }>).props + .children, + ); } return ""; } @@ -47,7 +102,11 @@ function MermaidBlock({ chart }: { chart: string }) { const renderChart = async () => { try { const mermaid = (await import("mermaid")).default; - mermaid.initialize({ startOnLoad: false, theme: "neutral", securityLevel: "loose" }); + mermaid.initialize({ + startOnLoad: false, + theme: "neutral", + securityLevel: "loose", + }); const { svg: rendered } = await mermaid.render(idRef.current, chart); if (mounted) { @@ -96,7 +155,8 @@ function MermaidBlock({ chart }: { chart: string }) { const GISCUS_REPO = process.env.NEXT_PUBLIC_GISCUS_REPO || ""; const GISCUS_REPO_ID = process.env.NEXT_PUBLIC_GISCUS_REPO_ID || ""; -const GISCUS_CATEGORY = process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "Announcements"; +const GISCUS_CATEGORY = + process.env.NEXT_PUBLIC_GISCUS_CATEGORY || "Announcements"; const GISCUS_CATEGORY_ID = process.env.NEXT_PUBLIC_GISCUS_CATEGORY_ID || ""; export function BlogDetailContent({ post }: BlogDetailContentProps) { @@ -109,7 +169,9 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { const update = () => { const bar = progressBarRef.current; if (!bar) return; - const docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight; + 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))}%`; }; @@ -140,7 +202,10 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) {
{post.category} - + {post.author} · {post.date} · {post.readTime}
@@ -153,7 +218,11 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { const text = extractText(children); const id = slugify(text); return ( - + {children} ); @@ -162,7 +231,11 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { const text = extractText(children); const id = slugify(text); return ( - + {children} ); @@ -171,7 +244,11 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { const text = extractText(children); const id = slugify(text); return ( - + {children} ); @@ -180,22 +257,35 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { const text = extractText(children); const id = slugify(text); return ( - + {children} ); }, p: ({ node, children, ...props }) => { - const hasImg = node?.children?.some((c: any) => c.tagName === "img"); + const hasImg = node?.children?.some( + (c: any) => c.tagName === "img", + ); if (hasImg) { return ( -
+
{children}
); } return ( - + {children} ); @@ -204,7 +294,9 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) {
    {children}
), ol: ({ children }) => ( -
    {children}
+
    + {children} +
), li: ({ children }) => (
  • @@ -216,7 +308,11 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { {children} @@ -229,37 +325,37 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) {
  • ), - hr: () => ( -
    - ), + hr: () =>
    , table: ({ children }) => (
    - {children}
    + + {children} +
    ), - thead: ({ children }) => {children}, - tr: ({ children }) => {children}, + thead: ({ children }) => ( + {children} + ), + tr: ({ children }) => ( + {children} + ), th: ({ children }) => ( {children} ), td: ({ children }) => ( - {children} + + {children} + ), img: ({ src, alt }) => ( - - {alt - {alt && ( -
    - - {alt} - -
    - )} -
    + ), code: ({ className, children }) => { const match = /language-(\w+)/.exec(className ?? ""); @@ -279,19 +375,14 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { } return ( - - {codeText} - + +
    + {language || "code"} +
    +
    +                          {codeText}
    +                        
    +
    ); }, }} @@ -330,7 +421,12 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) { className="fixed right-4 bottom-6 z-40 flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border bg-background shadow-lg transition-transform hover:scale-105 active:scale-95 lg:hidden" aria-label="İçindekiler" > - + {/* Mobil İçindekiler Drawer */} diff --git a/components/blog-toc.tsx b/components/blog-toc.tsx index 9dd9485..8ae8337 100644 --- a/components/blog-toc.tsx +++ b/components/blog-toc.tsx @@ -44,49 +44,66 @@ export function BlogToc({ markdown, onNavigate }: BlogTocProps) { const [activeId, setActiveId] = useState(""); const rafRef = useRef(0); - const handleClick = useCallback((id: string) => { - const target = document.getElementById(id); - if (!target) return; + const handleClick = useCallback( + (id: string) => { + const target = document.getElementById(id); + if (!target) return; - target.scrollIntoView({ behavior: "smooth", block: "start" }); - setActiveId(id); - onNavigate?.(); - }, [onNavigate]); + target.scrollIntoView({ behavior: "smooth", block: "start" }); + setActiveId(id); + onNavigate?.(); + }, + [onNavigate], + ); useEffect(() => { if (headings.length === 0) return; - const OFFSET = 120; + const headingElements = headings + .map((heading) => ({ + id: heading.id, + element: document.getElementById(heading.id), + })) + .filter((item): item is { id: string; element: HTMLElement } => + Boolean(item.element), + ); + + if (headingElements.length === 0) return; const updateActive = () => { - let current = ""; + let current = headingElements[0].id; - for (const heading of headings) { - const el = document.getElementById(heading.id); - if (!el) continue; - - const top = el.getBoundingClientRect().top; - if (top <= OFFSET) { - current = heading.id; + for (const item of headingElements) { + if (item.element.getBoundingClientRect().top <= 120) { + current = item.id; + continue; } + break; } - if (current) { - setActiveId(current); - } + setActiveId(current); }; - const onScroll = () => { - cancelAnimationFrame(rafRef.current); - rafRef.current = requestAnimationFrame(updateActive); - }; + const observer = new IntersectionObserver( + () => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(updateActive); + }, + { + root: null, + rootMargin: "-120px 0px -65% 0px", + threshold: [0, 1], + }, + ); - window.addEventListener("scroll", onScroll, { passive: true }); + for (const item of headingElements) { + observer.observe(item.element); + } - const timer = setTimeout(updateActive, 300); + const timer = setTimeout(updateActive, 200); return () => { - window.removeEventListener("scroll", onScroll); + observer.disconnect(); cancelAnimationFrame(rafRef.current); clearTimeout(timer); }; @@ -96,7 +113,10 @@ export function BlogToc({ markdown, onNavigate }: BlogTocProps) { return (