mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 13:17:07 +00:00
feat(web): blog-post ingestion webhook + /blog, dynamic RSS & sitemap
Add an autoblog webhook receiver and a Supabase-backed blog to logicsrc-web (the app had no Supabase usage before). - Migration: blog_posts table (RLS: public reads published, service-role writes). Applied to the linked project. - POST /api/webhooks/blog: verifies the Standard Webhooks signature against BLOG_WEBHOOK_SECRET via @profullstack/autoblog verifyAndParse (no admin user — shared secret only) and upserts the post by slug. - /blog index + /blog/[slug] render published posts from the table. - /blog/rss.xml and /sitemap.xml are now dynamic, generated from the table; removed the static public/sitemap.xml and public/blog/rss.xml. - BLOG_WEBHOOK_SECRET added to .env.example. Verified end-to-end: a signed sample post delivered 200 and appeared in the index, post page, RSS, and sitemap; build + typecheck pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ade606c157
commit
cf99e93c53
13 changed files with 549 additions and 82 deletions
96
apps/logicsrc-web/src/app/blog/[slug]/page.tsx
Normal file
96
apps/logicsrc-web/src/app/blog/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { publicClient } from "@/lib/supabase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PostRow = {
|
||||
slug: string;
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
html: string;
|
||||
featured_image: { url?: string } | null;
|
||||
published_at: string;
|
||||
};
|
||||
|
||||
async function loadPost(slug: string): Promise<PostRow | null> {
|
||||
const supabase = publicClient();
|
||||
const { data } = await supabase
|
||||
.from("blog_posts")
|
||||
.select("slug, title, excerpt, html, featured_image, published_at")
|
||||
.eq("slug", slug)
|
||||
.eq("status", "published")
|
||||
.maybeSingle();
|
||||
return (data as PostRow | null) ?? null;
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
if (!post) return { title: "Not found · LogicSRC" };
|
||||
return {
|
||||
title: `${post.title} · LogicSRC`,
|
||||
description: post.excerpt ?? undefined,
|
||||
alternates: { canonical: `/blog/${post.slug}` },
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt ?? undefined,
|
||||
type: "article",
|
||||
images: post.featured_image?.url ? [post.featured_image.url] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<ReactNode> {
|
||||
const { slug } = await params;
|
||||
const post = await loadPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<main style={{ maxWidth: 760, margin: "0 auto", padding: "64px 24px" }}>
|
||||
<p style={{ marginBottom: 32 }}>
|
||||
<Link href="/blog" style={{ color: "#5b6b7a", textDecoration: "none" }}>
|
||||
← Blog
|
||||
</Link>
|
||||
</p>
|
||||
<article>
|
||||
<h1 style={{ fontSize: 38, fontWeight: 800, margin: "0 0 8px" }}>
|
||||
{post.title}
|
||||
</h1>
|
||||
<div style={{ color: "#8a95a0", fontSize: 14, marginBottom: 32 }}>
|
||||
{formatDate(post.published_at)}
|
||||
</div>
|
||||
{post.featured_image?.url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={post.featured_image.url}
|
||||
alt={post.title}
|
||||
style={{ width: "100%", borderRadius: 12, margin: "0 0 32px" }}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="blog-content"
|
||||
dangerouslySetInnerHTML={{ __html: post.html }}
|
||||
/>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue