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:
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user