feat(blog): data-driven blog API + DB for content-pipeline publishing

Makes the blog publishable from the Süper Panel content pipeline (via n8n).
Backend mirrors the changelog module; frontend keeps the existing hand-authored
posts and merges in API-backed ones (no content migration, no regression).

API (apps/api):
- blog_posts Drizzle table (slug unique, title, meta_description, body_markdown,
  tags, cover_image, status, source, published_at)
- blog module: GET /blog/posts (public list), GET /blog/posts/:slug (public),
  POST /blog/posts/internal (Bearer BLOG_AUTOMATION_TOKEN, mirrors
  changelog/internal) — returns { ...post, url }
- BlogService: Drizzle + Redis cache, defensive Turkish-aware slugify +
  uniqueness; registered in app.module

Web (apps/web):
- use-blog hooks (list + by-slug, react-query, 30m staleTime)
- blog list: static + API merged, dedup by slug, newest first
- blog detail: static post renders as before; API post renders body_markdown
  via react-markdown (remark-gfm) styled to match existing prose
- add react-markdown + remark-gfm

Deploy: set BLOG_AUTOMATION_TOKEN (and optional PUBLIC_WEB_URL) on the api,
run drizzle-kit db:push to create blog_posts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 01:48:10 +03:00
parent 93d3b08992
commit e5c852b537
11 changed files with 1275 additions and 88 deletions

View File

@@ -1,4 +1,5 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { useBlogPosts } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
@@ -7,7 +8,11 @@ export const Route = createFileRoute("/blog")({
component: BlogPage,
});
const posts = [
type PostCard = { title: string; description: string; date: string; slug: string };
// Original hand-authored posts. Kept so they never disappear; auto-published
// posts from the content pipeline are merged in from the API below.
const staticPosts: PostCard[] = [
{
title: "Şase Numarası (VIN) Nedir? Nasıl Okunur?",
description:
@@ -45,6 +50,22 @@ function BlogPage() {
canonical: "https://sase.tr/blog",
});
const { data: apiPosts } = useBlogPosts();
// Merge static + API posts, dedupe by slug (static wins), newest first.
const bySlug = new Map<string, PostCard>();
for (const p of staticPosts) bySlug.set(p.slug, p);
for (const p of apiPosts ?? []) {
if (bySlug.has(p.slug)) continue;
bySlug.set(p.slug, {
slug: p.slug,
title: p.title,
description: p.metaDescription ?? "",
date: p.publishedAt.slice(0, 10),
});
}
const posts = [...bySlug.values()].sort((a, b) => b.date.localeCompare(a.date));
return (
<div className="min-h-screen">
<header className="border-b">

View File

@@ -1,7 +1,10 @@
import { usePageMeta } from "@/hooks/use-page-meta";
import { useBlogPost } from "@/hooks/use-blog";
import { Button } from "@sase/ui";
import { Link, createFileRoute, notFound } from "@tanstack/react-router";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// ─── BLOG POST DATA ───────────────────────────────────────────────────────────
@@ -341,27 +344,72 @@ const POSTS: Record<string, BlogPost> = {
},
};
// ─── MARKDOWN RENDERING (API-backed posts) ─────────────────────────────────────
// Maps markdown to the same prose styling the hand-authored posts use.
const mdComponents = {
h2: (props: React.ComponentProps<"h2">) => (
<h2 className="text-xl font-semibold text-foreground" {...props} />
),
h3: (props: React.ComponentProps<"h3">) => (
<h3 className="text-lg font-semibold text-foreground" {...props} />
),
ul: (props: React.ComponentProps<"ul">) => (
<ul className="list-disc space-y-2 pl-6" {...props} />
),
ol: (props: React.ComponentProps<"ol">) => (
<ol className="list-decimal space-y-2 pl-6" {...props} />
),
strong: (props: React.ComponentProps<"strong">) => (
<strong className="text-foreground" {...props} />
),
a: (props: React.ComponentProps<"a">) => (
<a className="text-foreground underline" {...props} />
),
};
function MarkdownBody({ markdown }: { markdown: string }) {
return (
<div className="space-y-6 leading-relaxed text-muted-foreground">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
{markdown}
</ReactMarkdown>
</div>
);
}
// ─── ROUTE ───────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/blog_/$slug")({
component: BlogPostPage,
beforeLoad: ({ params }) => {
if (!POSTS[params.slug]) {
throw notFound();
}
},
});
function BlogPostPage() {
const { slug } = Route.useParams();
const post = POSTS[slug];
const staticPost = POSTS[slug];
const { data: apiPost, isLoading, isError } = useBlogPost(slug, !staticPost);
const title = staticPost?.title ?? apiPost?.title ?? "Blog";
const description = staticPost?.description ?? apiPost?.metaDescription ?? "";
const date = staticPost?.date ?? apiPost?.publishedAt?.slice(0, 10) ?? "";
usePageMeta({
title: `${post.title} | Sase.tr Blog`,
description: post.description,
canonical: `https://sase.tr/blog/${post.slug}`,
title: `${title} | Sase.tr Blog`,
description,
canonical: `https://sase.tr/blog/${slug}`,
});
let body: React.ReactNode;
if (staticPost) body = staticPost.content;
else if (apiPost) body = <MarkdownBody markdown={apiPost.bodyMarkdown} />;
else if (isLoading) body = <p className="text-muted-foreground">Yükleniyor</p>;
else
body = (
<p className="text-muted-foreground">
{isError ? "Yazı bulunamadı." : "Yazı yüklenemedi."}
</p>
);
return (
<div className="min-h-screen">
<header className="border-b">
@@ -387,15 +435,15 @@ function BlogPostPage() {
Blog
</Link>
<ChevronRight className="size-3.5" />
<span className="text-foreground">{post.title}</span>
<span className="text-foreground">{title}</span>
</nav>
<article>
<time className="text-sm text-muted-foreground">{post.date}</time>
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{post.title}</h1>
<p className="mt-4 text-lg text-muted-foreground">{post.description}</p>
{date && <time className="text-sm text-muted-foreground">{date}</time>}
<h1 className="mt-3 text-3xl font-bold leading-tight sm:text-4xl">{title}</h1>
{description && <p className="mt-4 text-lg text-muted-foreground">{description}</p>}
<div className="mt-10">{post.content}</div>
<div className="mt-10">{body}</div>
</article>
{/* Back link */}