diff --git a/app/[locale]/agenda/[slug]/page.tsx b/app/[locale]/agenda/[slug]/page.tsx new file mode 100644 index 0000000..dad854a --- /dev/null +++ b/app/[locale]/agenda/[slug]/page.tsx @@ -0,0 +1,78 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { BlogDetailContent } from "@/components/blog-detail-content"; +import { ArticleJsonLd } from "@/components/json-ld"; +import { getBlogDetailBySlug } from "@/data/blog-detail"; +import { isNewsletterCategory } from "@/data/blog"; + +const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com"; + +type AgendaDetailPageProps = { + params: Promise<{ locale: string; slug: string }>; +}; + +export async function generateMetadata({ + params, +}: AgendaDetailPageProps): Promise { + const { locale, slug } = await params; + const post = await getBlogDetailBySlug(slug); + + if (!post || post.lang !== locale || !isNewsletterCategory(post.category)) { + return { title: locale === "en" ? "Agenda post not found" : "Gündem yazısı bulunamadı" }; + } + + const url = `${SITE_URL}${locale === "en" ? "/en" : ""}/agenda/${post.slug}`; + + return { + title: post.title, + description: post.excerpt, + alternates: { canonical: url }, + openGraph: { + title: post.title, + description: post.excerpt, + url, + type: "article", + publishedTime: post.date, + authors: [post.author], + images: [ + { + url: post.coverImage, + width: 1200, + height: 630, + alt: post.title, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: post.title, + description: post.excerpt, + images: [post.coverImage], + }, + }; +} + +export default async function AgendaDetailPage({ params }: AgendaDetailPageProps) { + const { locale, slug } = await params; + const post = await getBlogDetailBySlug(slug); + + if (!post || post.lang !== locale || !isNewsletterCategory(post.category)) { + notFound(); + } + + const url = `${SITE_URL}${locale === "en" ? "/en" : ""}/agenda/${post.slug}`; + + return ( + <> + + + + ); +} diff --git a/app/[locale]/agenda/page.tsx b/app/[locale]/agenda/page.tsx new file mode 100644 index 0000000..b594dd6 --- /dev/null +++ b/app/[locale]/agenda/page.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { BlogContent } from "@/components/blog-content"; +import { getAgendaPageData } from "@/data/blog"; + +type AgendaPageProps = { + params: Promise<{ locale: string }>; + searchParams?: Promise<{ + page?: string | string[]; + search?: string | string[]; + }>; +}; + +export async function generateMetadata({ params }: AgendaPageProps): Promise { + const { locale } = await params; + const t = await getTranslations({ locale, namespace: "Agenda" }); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default async function AgendaPage({ params, searchParams }: AgendaPageProps) { + const { locale } = await params; + const resolved = searchParams ? await searchParams : undefined; + const pageParam = Array.isArray(resolved?.page) ? resolved.page[0] : resolved?.page; + 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 getAgendaPageData(locale, currentPage, 12, searchParam); + + return ; +} diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx index bed89ad..26ded67 100644 --- a/app/[locale]/blog/[slug]/page.tsx +++ b/app/[locale]/blog/[slug]/page.tsx @@ -1,8 +1,9 @@ -import { notFound } from "next/navigation"; +import { notFound, permanentRedirect } from "next/navigation"; import { Metadata } from "next"; import { BlogDetailContent } from "@/components/blog-detail-content"; import { ArticleJsonLd } from "@/components/json-ld"; import { getBlogDetailBySlug } from "@/data/blog-detail"; +import { isNewsletterCategory } from "@/data/blog"; const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://poyrazavsever.com"; @@ -58,6 +59,11 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) { notFound(); } + if (isNewsletterCategory(post.category)) { + const localePrefix = locale === "tr" ? "" : `/${locale}`; + permanentRedirect(`${localePrefix}/agenda/${post.slug}`); + } + return ( <> 1) qs.set("page", String(params.page)); @@ -29,12 +33,15 @@ function buildHref(params: { page?: number; category?: string; search?: string } if (params.search) qs.set("search", params.search); const query = qs.toString(); - return query ? `/blog?${query}` : "/blog"; + const basePath = section === "agenda" ? "/agenda" : "/blog"; + return query ? `${basePath}?${query}` : basePath; } -export function BlogContent({ data }: BlogContentProps) { +export function BlogContent({ data, section = "blog" }: BlogContentProps) { const router = useRouter(); const t = useTranslations("Blog"); + const agendaT = useTranslations("Agenda"); + const isAgenda = section === "agenda"; const [searchInput, setSearchInput] = useState(data.searchQuery); const pageNumbers = Array.from({ length: data.totalPages }, (_, i) => i + 1); const hasArticles = data.articles.length > 0; @@ -42,15 +49,15 @@ export function BlogContent({ data }: BlogContentProps) { const submitSearch = useCallback( (value: string) => { const trimmed = value.trim(); - router.push(buildHref({ category: data.selectedCategory, search: trimmed })); + router.push(buildHref({ category: data.selectedCategory, search: trimmed }, section)); }, - [router, data.selectedCategory], + [router, data.selectedCategory, section], ); const clearFilters = useCallback(() => { setSearchInput(""); - router.push("/blog"); - }, [router]); + router.push(section === "agenda" ? "/agenda" : "/blog"); + }, [router, section]); return (
@@ -58,24 +65,26 @@ export function BlogContent({ data }: BlogContentProps) {
{/* Kategoriler */} -
- - {t("categories")}: - - {data.categories.map((category) => ( - - + + {t("categories")}: + + {data.categories.map((category) => ( + - {category} - - - ))} -
+ + {category} + + + ))} +
+ )} {/* Arama */}
@@ -86,14 +95,15 @@ export function BlogContent({ data }: BlogContentProps) { className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground" /> setSearchInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitSearch(searchInput); }} - placeholder={t("searchPlaceholder")} + placeholder={isAgenda ? agendaT("searchPlaceholder") : t("searchPlaceholder")} 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 && ( @@ -161,9 +171,13 @@ export function BlogContent({ data }: BlogContentProps) { /> {data.searchQuery - ? t("noSearch", { search: data.searchQuery }) + ? isAgenda + ? agendaT("noSearch", { search: data.searchQuery }) + : t("noSearch", { search: data.searchQuery }) : data.selectedCategory === "All" - ? t("empty") + ? isAgenda + ? agendaT("empty") + : t("empty") : t("emptyCategory", { category: data.selectedCategory })} {(data.searchQuery || data.selectedCategory !== "All") && ( @@ -185,11 +199,14 @@ export function BlogContent({ data }: BlogContentProps) { @@ -197,11 +214,14 @@ export function BlogContent({ data }: BlogContentProps) { {pageNumbers.map((page) => ( {page} @@ -211,11 +231,14 @@ export function BlogContent({ data }: BlogContentProps) { = data.totalPages} /> diff --git a/components/blog-detail-content.tsx b/components/blog-detail-content.tsx index e0d73a8..c2aa053 100644 --- a/components/blog-detail-content.tsx +++ b/components/blog-detail-content.tsx @@ -7,7 +7,7 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { Icon } from "@iconify/react"; import { Badge, Card, Typography } from "poyraz-ui/atoms"; import type { BlogDetail } from "@/data/blog-detail"; @@ -16,6 +16,7 @@ import { GiscusComments } from "@/components/giscus-comments"; type BlogDetailContentProps = { post: BlogDetail; + section?: "blog" | "agenda"; }; function isHttpUrl(value: string) { @@ -98,7 +99,8 @@ function MermaidBlock({ chart }: { chart: string }) { const t = useTranslations("Blog"); const [svg, setSvg] = useState(""); const [error, setError] = useState(""); - const idRef = useRef(`mermaid-${Math.random().toString(36).slice(2)}`); + const generatedId = useId(); + const idRef = useRef(`mermaid-${generatedId.replace(/:/g, "")}`); useEffect(() => { let mounted = true; @@ -163,8 +165,9 @@ 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) { +export function BlogDetailContent({ post, section = "blog" }: BlogDetailContentProps) { const t = useTranslations("Blog"); + const agendaT = useTranslations("Agenda"); const progressBarRef = useRef(null); const [tocOpen, setTocOpen] = useState(false); @@ -199,10 +202,10 @@ export function BlogDetailContent({ post }: BlogDetailContentProps) {
- {t("backToBlog")} + {section === "agenda" ? agendaT("backToAgenda") : t("backToBlog")}
diff --git a/components/site-navbar.tsx b/components/site-navbar.tsx index 6a0565f..3ea6c71 100644 --- a/components/site-navbar.tsx +++ b/components/site-navbar.tsx @@ -51,6 +51,10 @@ const SearchCommand = dynamic( ); const slowShineClassName = "[--poyraz-motion-duration-deliberate:1100ms]"; +const dropdownItemLinkClassName = + "grid w-full grid-cols-[1.25rem_minmax(0,1fr)] items-center gap-2"; +const mobileDropdownItemLinkClassName = + "grid min-h-10 w-full grid-cols-[1.25rem_minmax(0,1fr)_1rem] items-center gap-3 rounded-sm px-2 py-2 text-sm text-foreground/70 transition-colors hover:bg-muted hover:text-foreground"; function getNavLinkClass(isActive: boolean) { return [ @@ -280,18 +284,28 @@ export function SiteNavbar({ href={groupItem.href} target="_blank" rel="noreferrer" - className="flex items-center gap-2" + className={dropdownItemLinkClassName} > - - {t(groupItem.id)} + + {t(groupItem.id)} ) : ( - - {t(groupItem.id)} + + {t(groupItem.id)} )} @@ -430,15 +444,20 @@ export function SiteNavbar({ href={groupItem.href} target="_blank" rel="noreferrer" - className="flex min-h-10 w-full items-center gap-3 rounded-sm px-2 py-2 text-sm text-foreground/70 transition-colors hover:bg-muted hover:text-foreground" + className={mobileDropdownItemLinkClassName} > - - {t(groupItem.id)} + + {t(groupItem.id)} @@ -446,15 +465,20 @@ export function SiteNavbar({ - - {t(groupItem.id)} + + {t(groupItem.id)} diff --git a/lib/command-palette-links.ts b/lib/command-palette-links.ts index 5052697..f9bd0d9 100644 --- a/lib/command-palette-links.ts +++ b/lib/command-palette-links.ts @@ -112,6 +112,18 @@ export function getCommandPaletteGroups( icon: "mdi:file-document-outline", keywords: ["blog", locale === "tr" ? "yazı" : "post", "article"], }, + { + id: "agenda-index", + label: locale === "tr" ? "Haftalık Gündem" : "Weekly Agenda", + href: "/agenda", + icon: "mdi:newspaper-variant-outline", + keywords: [ + "newsletter", + locale === "tr" ? "gündem" : "agenda", + locale === "tr" ? "haftalık" : "weekly", + locale === "tr" ? "haber" : "news", + ], + }, { id: "blog-content-page", label: locale === "tr" ? "Video ve Notlar" : "Videos and Notes", diff --git a/lib/links.ts b/lib/links.ts index 4345464..5e2391a 100644 --- a/lib/links.ts +++ b/lib/links.ts @@ -77,6 +77,14 @@ export const NAV_DROPDOWN_GROUPS = [ icon: "mdi:dots-horizontal", insertAfter: "gallery", items: [ + { + id: "agenda", + label: "Haftalık Gündem", + href: "/agenda", + icon: "mdi:newspaper-variant-outline", + external: false, + keywords: ["gündem", "agenda", "newsletter", "haftalık", "weekly", "haber"], + }, { id: "animationResources", label: "Animasyon Kaynakları", diff --git a/messages/en.json b/messages/en.json index 1399a8f..7e2aaab 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2,6 +2,7 @@ "Nav": { "about": "About", "blog": "Blog", + "agenda": "Weekly Agenda", "content": "Content", "projects": "Projects", "gallery": "Gallery", @@ -87,6 +88,14 @@ "diagramLoading": "Diagram rendering...", "diagramError": "Could not render Mermaid diagram." }, + "Agenda": { + "title": "Weekly Agenda", + "description": "Weekly developments and commentary from software, technology, design, and artificial intelligence.", + "searchPlaceholder": "Search the weekly agenda...", + "empty": "No agenda posts yet.", + "noSearch": "No agenda posts found for \"{search}\".", + "backToAgenda": "<- Back to Weekly Agenda" + }, "AnimationSources": { "title": "Animation Sources", "description": "Production notes, prompts, and reusable code from animations I create for web and mobile products.", diff --git a/messages/tr.json b/messages/tr.json index 2954ecf..4c2e607 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -2,6 +2,7 @@ "Nav": { "about": "Hakkımda", "blog": "Blog", + "agenda": "Haftalık Gündem", "content": "İçerikler", "projects": "Projeler", "gallery": "Galeri", @@ -87,6 +88,14 @@ "diagramLoading": "Diyagram hazırlanıyor...", "diagramError": "Mermaid diyagramı oluşturulamadı." }, + "Agenda": { + "title": "Haftalık Gündem", + "description": "Yazılım, teknoloji, tasarım ve yapay zeka dünyasından haftalık gelişmeler ve yorumlar.", + "searchPlaceholder": "Haftalık gündemde ara...", + "empty": "Henüz gündem yazısı bulunmuyor.", + "noSearch": "\"{search}\" aramasına uygun gündem yazısı bulunamadı.", + "backToAgenda": "<- Haftalık Gündem'e dön" + }, "AnimationSources": { "title": "Animasyon Kaynakları", "description": "Web ve mobil ürünler için hazırladığım animasyonların üretim süreçleri, promptları ve uygulanabilir kodları.",