diff --git a/app/[locale]/animation-sources/[slug]/page.tsx b/app/[locale]/animation-sources/[slug]/page.tsx new file mode 100644 index 0000000..447bb10 --- /dev/null +++ b/app/[locale]/animation-sources/[slug]/page.tsx @@ -0,0 +1,96 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AnimationSourceDetailContent } from "@/components/animation-source-detail-content"; +import { ArticleJsonLd } from "@/components/json-ld"; +import { + getAnimationSourceBySlug, + listAnimationSources, +} from "@/data/animation-sources"; + +const SITE_URL = + process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com"; + +type AnimationSourceDetailPageProps = { + params: Promise<{ locale: string; slug: string }>; +}; + +function getLocalizedPath(locale: string, slug: string) { + const localePrefix = locale === "tr" ? "" : `/${locale}`; + return `${localePrefix}/animation-sources/${slug}`; +} + +export async function generateStaticParams() { + const sources = await listAnimationSources(); + return sources.map((source) => ({ + locale: source.lang, + slug: source.slug, + })); +} + +export const dynamicParams = false; + +export async function generateMetadata({ + params, +}: AnimationSourceDetailPageProps): Promise { + const { locale, slug } = await params; + const source = await getAnimationSourceBySlug(slug, locale); + + if (!source) { + return { title: locale === "en" ? "Source not found" : "Kaynak bulunamadı" }; + } + + const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`; + + return { + title: source.title, + description: source.excerpt, + alternates: { canonical: url }, + openGraph: { + title: source.title, + description: source.excerpt, + url, + type: "article", + publishedTime: source.date, + authors: [source.author], + images: [ + { + url: source.coverImage, + width: 720, + height: 720, + alt: source.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: source.title, + description: source.excerpt, + images: [source.coverImage], + }, + }; +} + +export default async function AnimationSourceDetailPage({ + params, +}: AnimationSourceDetailPageProps) { + const { locale, slug } = await params; + const source = await getAnimationSourceBySlug(slug, locale); + + if (!source) notFound(); + + const url = `${SITE_URL}${getLocalizedPath(locale, source.slug)}`; + + return ( + <> + + + + ); +} diff --git a/app/[locale]/animation-sources/page.tsx b/app/[locale]/animation-sources/page.tsx new file mode 100644 index 0000000..87739db --- /dev/null +++ b/app/[locale]/animation-sources/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { AnimationSourcesContent } from "@/components/animation-sources-content"; +import { listAnimationSources } from "@/data/animation-sources"; + +type AnimationSourcesPageProps = { + params: Promise<{ locale: string }>; +}; + +export async function generateMetadata({ + params, +}: AnimationSourcesPageProps): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "AnimationSources" }); + + return { + title: t("title"), + description: t("description"), + alternates: { + canonical: "/animation-sources", + }, + }; +} + +export default async function AnimationSourcesPage({ + params, +}: AnimationSourcesPageProps) { + const { locale } = await params; + const [sources, t] = await Promise.all([ + listAnimationSources(locale), + getTranslations({ locale, namespace: "AnimationSources" }), + ]); + + return ( + + ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 5ed7db8..fb99d3e 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,11 +1,15 @@ import { listBlogDetails } from "@/data/blog-detail"; +import { listAnimationSources } from "@/data/animation-sources"; import type { MetadataRoute } from "next"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com"; export default async function sitemap(): Promise { - const posts = await listBlogDetails(); + const [posts, animationSources] = await Promise.all([ + listBlogDetails(), + listAnimationSources(), + ]); const staticRoutes: MetadataRoute.Sitemap = [ { @@ -62,6 +66,12 @@ export default async function sitemap(): Promise { changeFrequency: "monthly", priority: 0.4, }, + { + url: `${SITE_URL}/animation-sources`, + lastModified: new Date(), + changeFrequency: "monthly", + priority: 0.7, + }, ]; const blogRoutes: MetadataRoute.Sitemap = posts.map((post) => ({ @@ -71,5 +81,14 @@ export default async function sitemap(): Promise { priority: 0.7, })); - return [...staticRoutes, ...blogRoutes]; + const animationSourceRoutes: MetadataRoute.Sitemap = animationSources.map( + (source) => ({ + url: `${SITE_URL}${source.lang === "en" ? "/en" : ""}/animation-sources/${source.slug}`, + lastModified: source.date ? new Date(source.date) : new Date(), + changeFrequency: "monthly", + priority: 0.6, + }), + ); + + return [...staticRoutes, ...blogRoutes, ...animationSourceRoutes]; } diff --git a/components/animation-source-detail-content.tsx b/components/animation-source-detail-content.tsx new file mode 100644 index 0000000..24a2224 --- /dev/null +++ b/components/animation-source-detail-content.tsx @@ -0,0 +1,404 @@ +"use client"; + +import Image from "next/image"; +import { Icon } from "@iconify/react"; +import { useTranslations } from "next-intl"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import remarkGfm from "remark-gfm"; +import { Badge, Card, Typography } from "poyraz-ui/atoms"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "poyraz-ui/molecules"; +import { ArticleToc } from "@/components/article-toc"; +import type { AnimationSource } from "@/data/animation-sources"; +import { Link } from "@/i18n/routing"; +import { slugifyMarkdownHeading } from "@/lib/markdown-headings"; + +type AnimationSourceDetailContentProps = { + source: AnimationSource; +}; + +function extractText(children: React.ReactNode): string { + if (typeof children === "string" || typeof children === "number") { + return String(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 ""; +} + +async function copyToClipboard(value: string) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +} + +function CopyableCodeBlock({ + code, + language, +}: { + code: string; + language: string; +}) { + const t = useTranslations("AnimationSources"); + const [copied, setCopied] = useState(false); + const resetTimerRef = useRef(null); + + useEffect(() => { + return () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }; + }, []); + + const handleCopy = async () => { + try { + await copyToClipboard(code); + setCopied(true); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + }; + + const displayLanguage = language || "text"; + const highlighterLanguage = language === "prompt" ? "text" : displayLanguage; + + return ( + +
+ + {displayLanguage} + + + + + + + {copied ? t("copied") : t("copy")} + + +
+ + {code} + +
+ ); +} + +function MarkdownImage({ src, alt }: { src?: string; alt?: string }) { + if (!src || !src.startsWith("/")) return null; + + return ( + + {alt + {alt ? ( + + {alt} + + ) : null} + + ); +} + +export function AnimationSourceDetailContent({ + source, +}: AnimationSourceDetailContentProps) { + const t = useTranslations("AnimationSources"); + const progressBarRef = useRef(null); + const [tocOpen, setTocOpen] = useState(false); + const closeToc = useCallback(() => setTocOpen(false), []); + + useEffect(() => { + let rafId = 0; + + const update = () => { + rafId = 0; + const bar = progressBarRef.current; + if (!bar) return; + + const scrollableHeight = + document.documentElement.scrollHeight - + document.documentElement.clientHeight; + const progress = + scrollableHeight <= 0 ? 100 : (window.scrollY / scrollableHeight) * 100; + bar.style.width = `${Math.min(100, Math.max(0, progress))}%`; + }; + + const handleScroll = () => { + if (rafId !== 0) return; + rafId = window.requestAnimationFrame(update); + }; + + window.addEventListener("scroll", handleScroll, { passive: true }); + update(); + + return () => { + window.removeEventListener("scroll", handleScroll); + if (rafId !== 0) window.cancelAnimationFrame(rafId); + }; + }, []); + + return ( + <> +
+
+
+ +
+
+
+ + + {t("back")} + + +
+
+ {source.platform} + {source.tools.map((tool) => ( + + {tool} + + ))} +
+ {source.title} + + {source.excerpt} + + + {source.author} · {source.date} + +
+ +
+ ( + + {children} + + ), + h2: ({ children }) => ( + + {children} + + ), + h3: ({ children }) => ( + + {children} + + ), + p: ({ node, children }) => { + const containsImage = node?.children.some( + (child) => + child.type === "element" && child.tagName === "img", + ); + + if (containsImage) { + return
{children}
; + } + + return ( + + {children} + + ); + }, + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + a: ({ href, children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( + +
{children}
+
+ ), + hr: () =>
, + table: ({ children }) => ( + + + {children} +
+
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + img: ({ src, alt }) => ( + + ), + pre: ({ children }) => <>{children}, + code: ({ className, children }) => { + const match = /language-([\w-]+)/.exec(className ?? ""); + if (!match) { + return ( + + {children} + + ); + } + + return ( + + ); + }, + }} + > + {source.markdown} + +
+
+
+ + +
+ + + + {tocOpen ? ( +
+
+
event.stopPropagation()} + > +
+ {t("toc")} + +
+ +
+
+ ) : null} + + ); +} diff --git a/components/animation-sources-content.tsx b/components/animation-sources-content.tsx new file mode 100644 index 0000000..900df8b --- /dev/null +++ b/components/animation-sources-content.tsx @@ -0,0 +1,81 @@ +import Image from "next/image"; +import { Badge, Card, Typography } from "poyraz-ui/atoms"; +import { Link } from "@/i18n/routing"; +import type { AnimationSource } from "@/data/animation-sources"; + +type AnimationSourcesContentProps = { + sources: AnimationSource[]; + labels: { + title: string; + description: string; + empty: string; + itemCount: string; + }; +}; + +export function AnimationSourcesContent({ + sources, + labels, +}: AnimationSourcesContentProps) { + return ( +
+
+
+
+ {labels.title} + + {labels.description} + +
+ + {labels.itemCount} + +
+
+ + {sources.length > 0 ? ( +
+ {sources.map((source) => ( + + +
+ +
+
+ + {source.platform} + + + {source.title} + +
+
+ + ))} +
+ ) : ( + + + {labels.empty} + + + )} +
+ ); +} diff --git a/components/article-toc.tsx b/components/article-toc.tsx new file mode 100644 index 0000000..6a17e83 --- /dev/null +++ b/components/article-toc.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Typography } from "poyraz-ui/atoms"; +import { parseMarkdownHeadings } from "@/lib/markdown-headings"; + +type ArticleTocProps = { + markdown: string; + title: string; + onNavigate?: () => void; +}; + +export function ArticleToc({ markdown, title, onNavigate }: ArticleTocProps) { + const headings = useMemo(() => parseMarkdownHeadings(markdown), [markdown]); + const [activeId, setActiveId] = useState(""); + const rafRef = useRef(0); + + const handleClick = useCallback( + (id: string) => { + const target = document.getElementById(id); + if (!target) return; + + target.scrollIntoView({ behavior: "smooth", block: "start" }); + setActiveId(id); + onNavigate?.(); + }, + [onNavigate], + ); + + useEffect(() => { + if (headings.length === 0) return; + + 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 = headingElements[0].id; + + for (const item of headingElements) { + if (item.element.getBoundingClientRect().top <= 120) { + current = item.id; + continue; + } + break; + } + + setActiveId(current); + }; + + const observer = new IntersectionObserver( + () => { + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(updateActive); + }, + { + rootMargin: "-120px 0px -65% 0px", + threshold: [0, 1], + }, + ); + + for (const item of headingElements) { + observer.observe(item.element); + } + + const timer = window.setTimeout(updateActive, 200); + + return () => { + observer.disconnect(); + cancelAnimationFrame(rafRef.current); + window.clearTimeout(timer); + }; + }, [headings]); + + if (headings.length < 2) return null; + + return ( + + ); +} diff --git a/components/site-navbar.tsx b/components/site-navbar.tsx index 045da0d..0867a37 100644 --- a/components/site-navbar.tsx +++ b/components/site-navbar.tsx @@ -60,6 +60,15 @@ function getNavLinkClass(isActive: boolean) { ].join(" "); } +function getDesktopDropdownTriggerClass(isActive: boolean) { + return [ + "relative inline-flex h-7 shrink-0 cursor-pointer items-center justify-center gap-1 whitespace-nowrap rounded-sm px-2.5 text-xs font-medium outline-none", + "transition-[color,background-color] duration-[var(--poyraz-motion-duration-fast)] ease-[var(--poyraz-motion-ease-out)]", + "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + isActive ? "text-foreground" : "text-muted-foreground hover:text-foreground", + ].join(" "); +} + type ThemeToggleProps = { theme: ThemeMode; onThemeChange: (theme: ThemeMode) => void; @@ -237,13 +246,13 @@ export function SiteNavbar({ theme, onThemeChange }: SiteNavbarProps) {