Add Ghost CMS blog section (list + article pages)

This commit is contained in:
2026-07-23 20:59:29 +05:30
parent 9a42e8b6e5
commit 5e457f4f39
9 changed files with 767 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Ghost CMS — Content API (read-only). Copy this file to .env.local and fill in.
# Get these from Ghost Admin → Settings → Integrations → Add custom integration.
GHOST_URL=https://your-ghost-site.com
GHOST_CONTENT_API_KEY=your_content_api_key
# Admin API key (id:secret) — only needed for server-side write tooling, not the blog.
GHOST_ADMIN_API_KEY=
# Optional
GHOST_API_VERSION=v5.0
GHOST_REVALIDATE=60
+1
View File
@@ -3,6 +3,7 @@
/out
/build
.env*
!.env.example
.DS_Store
*.tsbuildinfo
next-env.d.ts
+114
View File
@@ -0,0 +1,114 @@
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getPost, getPosts, postExcerpt, formatDate, readingTime } from "@/lib/ghost";
export const revalidate = 60;
// Pre-render every known post at build time; new ones are handled on demand.
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return { title: "Post not found — UP Parivaar Dallas" };
return {
title: `${post.title} — UP Parivaar Dallas`,
description: postExcerpt(post, 160),
openGraph: {
title: post.title,
description: postExcerpt(post, 160),
type: "article",
images: post.feature_image ? [{ url: post.feature_image }] : undefined,
},
};
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
const author = post.primary_author;
return (
<article className="page-glow blogpost">
<div className="container blogpost__inner">
<Link href="/blog" className="blogpost__back">
All posts
</Link>
<div className="blogpost__head">
{post.primary_tag && <span className="blog-tag">{post.primary_tag.name}</span>}
<h1 className="display-title ts-34-44 blogpost__title">{post.title}</h1>
<div className="blogpost__byline">
{author?.profile_image && (
<span className="blogpost__avatar">
<Image src={author.profile_image} alt={author.name} fill className="img-cover" sizes="44px" />
</span>
)}
<div className="blogpost__byline-text">
{author && <span className="blogpost__author">{author.name}</span>}
<span className="blog-meta">
<span>{formatDate(post.published_at)}</span>
{readingTime(post) && <span className="blog-meta__dot"></span>}
{readingTime(post) && <span>{readingTime(post)}</span>}
</span>
</div>
</div>
</div>
{post.feature_image && (
<div className="blogpost__hero">
<Image
src={post.feature_image}
alt={post.feature_image_alt || post.title}
fill
className="img-cover"
sizes="(max-width: 900px) 100vw, 900px"
priority
/>
</div>
)}
{post.html ? (
<div
className="blogpost__content"
dangerouslySetInnerHTML={{ __html: post.html }}
/>
) : (
<p className="blogpost__content">{postExcerpt(post, 500)}</p>
)}
{post.tags && post.tags.length > 0 && (
<div className="blogpost__tags">
{post.tags.map((t) => (
<span key={t.id} className="blog-tag blog-tag--muted">
{t.name}
</span>
))}
</div>
)}
<div className="blogpost__foot">
<Link href="/blog" className="link-arrow link-arrow--spaced-lg">
Back to all posts
</Link>
</div>
</div>
</article>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { getPosts } from "@/lib/ghost";
import { BlogCard, FeaturedBlogCard } from "@/components/BlogCard";
export const metadata = {
title: "Blog — UP Parivaar Dallas",
description:
"Stories, event recaps, and reflections from the UP Parivaar Dallas community.",
};
// Revalidation is driven by the Ghost fetch layer (GHOST_REVALIDATE).
export const revalidate = 60;
export default async function BlogPage() {
const posts = await getPosts();
const [featured, ...rest] = posts;
return (
<>
<section className="page-glow page-hero--tight">
<div className="container hero-center">
<span className="eyebrow">Blog</span>
<h1 className="display-title ts-36-48 events-hero__title">
Stories from our community
</h1>
<p className="events-hero__lead">
Event recaps, member spotlights, and reflections on the traditions we
carry forward together.
</p>
</div>
</section>
<section className="section-white">
<div className="container blog-list">
{posts.length === 0 ? (
<p className="blog-empty">No posts published yet. Check back soon.</p>
) : (
<>
{featured && <FeaturedBlogCard post={featured} />}
{rest.length > 0 && (
<div className="blog-grid">
{rest.map((post) => (
<BlogCard key={post.id} post={post} />
))}
</div>
)}
</>
)}
</div>
</section>
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Image from "next/image";
import Link from "next/link";
import { GhostPost, postExcerpt, formatDate, readingTime } from "@/lib/ghost";
/** Shared cover: real Ghost feature image, or a branded fallback when null. */
function Cover({ post, sizes }: { post: GhostPost; sizes: string }) {
if (post.feature_image) {
return (
<Image
src={post.feature_image}
alt={post.feature_image_alt || post.title}
fill
className="img-cover"
sizes={sizes}
/>
);
}
return (
<div className="blog-card__placeholder" aria-hidden>
<span>{(post.primary_tag?.name || post.title).charAt(0)}</span>
</div>
);
}
export function FeaturedBlogCard({ post }: { post: GhostPost }) {
return (
<Link href={`/blog/${post.slug}`} className="blog-feature">
<div className="blog-feature__media">
<Cover post={post} sizes="(max-width: 900px) 100vw, 640px" />
</div>
<div className="blog-feature__body">
{post.primary_tag && <span className="blog-tag">{post.primary_tag.name}</span>}
<h2 className="blog-feature__title">{post.title}</h2>
<p className="blog-feature__excerpt">{postExcerpt(post, 220)}</p>
<div className="blog-meta">
<span>{formatDate(post.published_at)}</span>
{readingTime(post) && <span className="blog-meta__dot"></span>}
{readingTime(post) && <span>{readingTime(post)}</span>}
</div>
<span className="link-arrow link-arrow--spaced-lg">Read article</span>
</div>
</Link>
);
}
export function BlogCard({ post }: { post: GhostPost }) {
return (
<Link href={`/blog/${post.slug}`} className="blog-card">
<div className="blog-card__media">
<Cover post={post} sizes="(max-width: 640px) 100vw, 400px" />
{post.primary_tag && <span className="blog-tag blog-tag--float">{post.primary_tag.name}</span>}
</div>
<div className="blog-card__body">
<h3 className="blog-card__title">{post.title}</h3>
<p className="blog-card__excerpt">{postExcerpt(post)}</p>
<div className="blog-meta">
<span>{formatDate(post.published_at)}</span>
{readingTime(post) && <span className="blog-meta__dot"></span>}
{readingTime(post) && <span>{readingTime(post)}</span>}
</div>
</div>
</Link>
);
}
+1
View File
@@ -10,6 +10,7 @@ const links = [
{ href: "/about", label: "About" },
{ href: "/events", label: "Events" },
{ href: "/knowledge", label: "Knowledge" },
{ href: "/blog", label: "Blog" },
{ href: "/gallery", label: "Gallery" },
{ href: "/why-join", label: "Why Join" },
{ href: "/contact", label: "Contact" },
+210
View File
@@ -0,0 +1,210 @@
/* --------------------------------------------------------------------------
Ghost CMS — Content API client
--------------------------------------------------------------------------
Talks to a self-hosted or Ghost(Pro) instance via the read-only Content API.
Configure with two env vars (see .env.example):
GHOST_URL e.g. https://cms.upparivaar.com
GHOST_CONTENT_API_KEY the Content API key from Ghost Admin → Integrations
When those are missing (or a request fails) we fall back to a small set of
built-in demo posts so the /blog section still renders during local dev.
-------------------------------------------------------------------------- */
const GHOST_URL = process.env.GHOST_URL?.replace(/\/$/, "");
const GHOST_KEY = process.env.GHOST_CONTENT_API_KEY;
const GHOST_API_VERSION = process.env.GHOST_API_VERSION ?? "v5.0";
// How long (seconds) Next.js caches Ghost responses before revalidating.
const REVALIDATE = Number(process.env.GHOST_REVALIDATE ?? 60);
export const ghostConfigured = Boolean(GHOST_URL && GHOST_KEY);
export type GhostAuthor = {
id: string;
name: string;
slug: string;
profile_image: string | null;
bio: string | null;
};
export type GhostTag = {
id: string;
name: string;
slug: string;
};
export type GhostPost = {
id: string;
slug: string;
title: string;
html: string | null;
excerpt: string | null;
custom_excerpt: string | null;
feature_image: string | null;
feature_image_alt: string | null;
featured: boolean;
reading_time: number | null;
published_at: string | null;
updated_at: string | null;
tags?: GhostTag[];
authors?: GhostAuthor[];
primary_author?: GhostAuthor | null;
primary_tag?: GhostTag | null;
};
const POST_FIELDS =
"id,slug,title,html,excerpt,custom_excerpt,feature_image,feature_image_alt,featured,reading_time,published_at,updated_at";
function buildUrl(path: string, params: Record<string, string> = {}) {
const url = new URL(`${GHOST_URL}/ghost/api/content${path}`);
url.searchParams.set("key", GHOST_KEY as string);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
return url.toString();
}
async function ghostFetch<T>(path: string, params: Record<string, string>): Promise<T | null> {
if (!ghostConfigured) return null;
try {
const res = await fetch(buildUrl(path, params), {
headers: { "Accept-Version": GHOST_API_VERSION },
next: { revalidate: REVALIDATE, tags: ["ghost-posts"] },
});
if (!res.ok) {
console.warn(`[ghost] ${path} responded ${res.status}`);
return null;
}
return (await res.json()) as T;
} catch (err) {
console.warn("[ghost] request failed:", err);
return null;
}
}
/** All published posts, newest first. Falls back to demo posts when unconfigured. */
export async function getPosts(): Promise<GhostPost[]> {
const data = await ghostFetch<{ posts: GhostPost[] }>("/posts/", {
include: "tags,authors",
fields: POST_FIELDS,
limit: "all",
order: "published_at desc",
filter: "status:published",
});
return data?.posts ?? DEMO_POSTS;
}
/** A single post by slug, with full HTML. Returns null when not found. */
export async function getPost(slug: string): Promise<GhostPost | null> {
const data = await ghostFetch<{ posts: GhostPost[] }>(`/posts/slug/${slug}/`, {
include: "tags,authors",
});
if (data?.posts?.[0]) return data.posts[0];
return DEMO_POSTS.find((p) => p.slug === slug) ?? null;
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
export function postExcerpt(post: GhostPost, max = 160): string {
const text = post.custom_excerpt || post.excerpt || "";
if (text.length <= max) return text;
return text.slice(0, max).trimEnd() + "…";
}
export function formatDate(iso: string | null): string {
if (!iso) return "";
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function readingTime(post: GhostPost): string {
const mins = post.reading_time ?? 0;
return mins > 0 ? `${mins} min read` : "";
}
/* -------------------------------------------------------------------------- */
/* Demo fallback content (used only when Ghost isn't configured) */
/* -------------------------------------------------------------------------- */
const demoAuthor: GhostAuthor = {
id: "demo-author",
name: "UP Parivaar Dallas Team",
slug: "team",
profile_image: null,
bio: "Sharing stories from the community.",
};
function demoPost(
slug: string,
title: string,
tag: string,
image: string,
excerpt: string,
body: string,
publishedAt: string,
reading: number,
): GhostPost {
return {
id: slug,
slug,
title,
html: body,
excerpt,
custom_excerpt: excerpt,
feature_image: image,
feature_image_alt: title,
featured: false,
reading_time: reading,
published_at: publishedAt,
updated_at: publishedAt,
tags: [{ id: tag, name: tag, slug: tag.toLowerCase() }],
authors: [demoAuthor],
primary_author: demoAuthor,
primary_tag: { id: tag, name: tag, slug: tag.toLowerCase() },
};
}
export const DEMO_POSTS: GhostPost[] = [
demoPost(
"welcome-to-up-parivaar-dallas",
"Welcome to the UP Parivaar Dallas Blog",
"Community",
"https://images.unsplash.com/photo-1511578314322-379afb476865?w=1200&q=80",
"A new home for stories, updates, and reflections from our growing family in the DallasFort Worth metroplex.",
`<p>Welcome to our community blog. This is where we share stories, event recaps, and reflections from the UP Parivaar Dallas family.</p>
<h2>Why we started this space</h2>
<p>Our community has grown into hundreds of families who care deeply about staying connected to their roots. This blog is a place to celebrate that journey — one story at a time.</p>
<p>Expect posts about our festivals, volunteer initiatives, member spotlights, and the traditions we carry forward for the next generation.</p>`,
"2026-07-10T09:00:00.000Z",
3,
),
demoPost(
"diwali-mahotsav-recap",
"Diwali Mahotsav 2026 — A Festival of Lights & Togetherness",
"Events",
"https://images.unsplash.com/photo-1605952375858-c5c2e3fd0f45?w=1200&q=80",
"Hundreds of families came together for an unforgettable evening of culture, food, and community spirit.",
`<p>This year's Diwali Mahotsav was our largest celebration yet, bringing families from across the metroplex together for an evening of joy and tradition.</p>
<h2>Highlights</h2>
<ul><li>Cultural dance and music performances</li><li>Traditional food stalls</li><li>A dedicated kids' zone</li><li>Community networking</li></ul>
<p>Thank you to every volunteer, performer, and family who made the night special. We can't wait to do it again next year.</p>`,
"2026-06-28T18:00:00.000Z",
4,
),
demoPost(
"preserving-our-roots",
"Preserving Our Roots for the Next Generation",
"Culture",
"https://images.unsplash.com/photo-1528605248644-14dd04022da1?w=1200&q=80",
"How our community keeps the language, values, and traditions of Uttar Pradesh alive far from home.",
`<p>Living thousands of miles from home doesn't mean losing touch with who we are. Across our community, families are finding creative ways to pass their heritage on to children born and raised in Dallas.</p>
<h2>Small traditions, big impact</h2>
<p>From weekend language circles to festival preparations at home, these shared moments become the memories our children will carry forward.</p>`,
"2026-06-12T10:30:00.000Z",
5,
),
];
+11
View File
@@ -1,8 +1,19 @@
/** @type {import('next').NextConfig} */
// Allow next/image to load Ghost feature images from the configured CMS host.
const ghostHost = (() => {
try {
return process.env.GHOST_URL ? new URL(process.env.GHOST_URL).hostname : null;
} catch {
return null;
}
})();
const nextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "images.unsplash.com" },
...(ghostHost ? [{ protocol: "https", hostname: ghostHost }] : []),
],
},
};
+303
View File
@@ -3642,3 +3642,306 @@ h6 {
.banner-pad-28 {
padding-block: 112px;
}
/* --------------------------------------------------------------------------
25. Blog (Ghost CMS) — list + article
-------------------------------------------------------------------------- */
.blog-list {
display: flex;
flex-direction: column;
gap: 56px;
}
.blog-empty {
text-align: center;
color: var(--ink-500);
padding-block: 64px;
}
/* Shared tag pill */
.blog-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 5px 12px;
border-radius: 999px;
background: var(--brand-cream2);
color: var(--brand-orange);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.blog-tag--muted {
background: #f2f2f2;
color: var(--ink-500);
}
.blog-tag--float {
position: absolute;
top: 14px;
left: 14px;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(4px);
}
/* Shared meta line (date • reading time) */
.blog-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--ink-500);
}
.blog-meta__dot {
opacity: 0.5;
}
/* Fallback cover when a post has no feature image */
.blog-card__placeholder {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: linear-gradient(135deg, var(--brand-orange), var(--brand-light));
}
.blog-card__placeholder span {
font-family: var(--font-display);
font-size: 64px;
color: rgba(255, 255, 255, 0.9);
}
/* Featured (first) post */
.blog-feature {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 40px;
align-items: center;
background: #fff;
border: 1px solid #f0eae4;
border-radius: 24px;
overflow: hidden;
box-shadow: var(--shadow-soft);
transition: transform 0.35s ease, box-shadow 0.35s ease;
}
.blog-feature:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-card);
}
.blog-feature__media {
position: relative;
aspect-ratio: 16 / 11;
min-height: 320px;
}
.blog-feature__body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 40px 44px 40px 4px;
}
.blog-feature__title {
font-family: var(--font-display);
font-size: clamp(24px, 3vw, 34px);
line-height: 1.2;
color: var(--ink-900);
}
.blog-feature__excerpt {
color: var(--ink-500);
line-height: 1.65;
}
/* Grid of remaining posts */
.blog-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 28px;
}
.blog-card {
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid #f0eae4;
border-radius: 20px;
overflow: hidden;
transition: transform 0.35s ease, box-shadow 0.35s ease;
}
.blog-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-card);
}
.blog-card__media {
position: relative;
aspect-ratio: 16 / 10;
}
.blog-card__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 22px 22px 26px;
flex: 1;
}
.blog-card__title {
font-family: var(--font-display);
font-size: 20px;
line-height: 1.3;
color: var(--ink-900);
}
.blog-card__excerpt {
color: var(--ink-500);
font-size: 14px;
line-height: 1.6;
flex: 1;
}
/* Single article */
.blogpost {
padding-bottom: 96px;
}
.blogpost__inner {
max-width: 820px;
}
.blogpost__back {
display: inline-block;
margin-top: 8px;
color: var(--ink-500);
font-size: 14px;
font-weight: 500;
transition: color 0.2s ease;
}
.blogpost__back:hover {
color: var(--brand-orange);
}
.blogpost__head {
display: flex;
flex-direction: column;
gap: 18px;
margin-top: 24px;
}
.blogpost__title {
color: var(--ink-900);
}
.blogpost__byline {
display: flex;
align-items: center;
gap: 14px;
}
.blogpost__avatar {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.blogpost__byline-text {
display: flex;
flex-direction: column;
gap: 3px;
}
.blogpost__author {
font-weight: 600;
color: var(--ink-800);
}
.blogpost__hero {
position: relative;
aspect-ratio: 16 / 9;
margin-top: 32px;
border-radius: 20px;
overflow: hidden;
}
/* Rendered Ghost HTML */
.blogpost__content {
margin-top: 40px;
color: var(--ink-800);
font-size: 17px;
line-height: 1.8;
}
.blogpost__content > * + * {
margin-top: 22px;
}
.blogpost__content h2 {
font-family: var(--font-display);
font-size: 26px;
line-height: 1.25;
color: var(--ink-900);
margin-top: 40px;
}
.blogpost__content h3 {
font-family: var(--font-display);
font-size: 21px;
color: var(--ink-900);
margin-top: 32px;
}
.blogpost__content a {
color: var(--brand-orange);
text-decoration: underline;
text-underline-offset: 3px;
}
.blogpost__content ul,
.blogpost__content ol {
padding-left: 22px;
list-style: revert;
}
.blogpost__content li + li {
margin-top: 8px;
}
.blogpost__content img {
border-radius: 16px;
margin-inline: auto;
}
.blogpost__content blockquote {
border-left: 3px solid var(--brand-orange);
padding-left: 20px;
color: var(--ink-500);
font-style: italic;
}
.blogpost__content figure {
margin-block: 12px;
}
.blogpost__content figcaption {
text-align: center;
font-size: 13px;
color: var(--ink-400);
margin-top: 8px;
}
.blogpost__content pre {
background: #1c0e03;
color: #fbe8dc;
padding: 18px 20px;
border-radius: 12px;
overflow-x: auto;
font-size: 14px;
}
.blogpost__content code {
font-size: 0.92em;
}
.blogpost__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 40px;
padding-top: 28px;
border-top: 1px solid #f0eae4;
}
.blogpost__foot {
margin-top: 32px;
}
@media (max-width: 900px) {
.blog-feature {
grid-template-columns: 1fr;
}
.blog-feature__media {
min-height: 240px;
}
.blog-feature__body {
padding: 8px 28px 36px;
}
.blog-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 560px) {
.blog-grid {
grid-template-columns: 1fr;
}
}