Organize app using Next.js route groups
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import {
|
||||
getBlogPostBySlug,
|
||||
getAllBlogPosts as getAllPosts,
|
||||
type BlogPost,
|
||||
} from "../../../../lib/content";
|
||||
import { logger } from "../../../../lib/logger";
|
||||
import ContentBanner from "../../../components/sections/ContentBanner";
|
||||
import AskOrganizer from "../../../components/sections/AskOrganizer";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import "../blog.css";
|
||||
|
||||
// Code split RelatedArticles - blog-specific, below the fold
|
||||
const RelatedArticles = dynamic(
|
||||
() => import("../../../components/sections/RelatedArticles"),
|
||||
{
|
||||
loading: () => (
|
||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||
),
|
||||
ssr: true,
|
||||
},
|
||||
);
|
||||
|
||||
// AskOrganizer data - same as index page
|
||||
const askOrganizerData = {
|
||||
title: "Still have questions?",
|
||||
subtitle: "Get answers from an experienced organizer",
|
||||
buttonText: "Ask an organizer",
|
||||
buttonHref: "#contact",
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static params for all blog posts
|
||||
* This enables static generation for all blog posts at build time
|
||||
*/
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const posts = getAllPosts();
|
||||
return posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error("Error generating static params:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata for each blog post
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: PageProps): Promise<Metadata> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
const post = getBlogPostBySlug(slug);
|
||||
|
||||
if (!post) {
|
||||
return {
|
||||
title: "Post Not Found",
|
||||
description: "The requested blog post could not be found.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
authors: [{ name: post.frontmatter.author }],
|
||||
openGraph: {
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
type: "article",
|
||||
publishedTime: post.frontmatter.date,
|
||||
authors: [post.frontmatter.author],
|
||||
url: `https://communityrule.com/blog/${slug}`,
|
||||
siteName: "CommunityRule",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
creator: "@communityrule",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error generating metadata:", error);
|
||||
return {
|
||||
title: "Blog Post",
|
||||
description: "A blog post from our community.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic blog post page
|
||||
*/
|
||||
export default async function BlogPostPage({ params }: PageProps) {
|
||||
// Get the blog post data
|
||||
const { slug } = await params;
|
||||
const post = getBlogPostBySlug(slug);
|
||||
|
||||
// If post doesn't exist, show 404
|
||||
if (!post) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Get related articles with improved algorithm
|
||||
const allPosts = getAllPosts();
|
||||
|
||||
// Create slug order for consistent background cycling
|
||||
const slugOrder = allPosts.map((post) => post.slug);
|
||||
|
||||
// Simple related articles algorithm based on content similarity
|
||||
const getRelatedArticles = (
|
||||
currentPost: BlogPost,
|
||||
allPosts: BlogPost[],
|
||||
limit = 3,
|
||||
): BlogPost[] => {
|
||||
const otherPosts = allPosts.filter((p) => p.slug !== currentPost.slug);
|
||||
|
||||
// Score posts based on content similarity
|
||||
const scoredPosts = otherPosts.map((post) => {
|
||||
let score = 0;
|
||||
|
||||
// Check for similar keywords in title and description
|
||||
const currentTitle = currentPost.frontmatter.title.toLowerCase();
|
||||
const currentDesc = currentPost.frontmatter.description.toLowerCase();
|
||||
const postTitle = post.frontmatter.title.toLowerCase();
|
||||
const postDesc = post.frontmatter.description.toLowerCase();
|
||||
|
||||
// Common keywords that indicate similarity
|
||||
const keywords = [
|
||||
"community",
|
||||
"conflict",
|
||||
"decision",
|
||||
"governance",
|
||||
"security",
|
||||
"trust",
|
||||
"collaboration",
|
||||
"organization",
|
||||
];
|
||||
|
||||
keywords.forEach((keyword) => {
|
||||
if (currentTitle.includes(keyword) && postTitle.includes(keyword))
|
||||
score += 3;
|
||||
if (currentDesc.includes(keyword) && postDesc.includes(keyword))
|
||||
score += 2;
|
||||
if (currentTitle.includes(keyword) && postDesc.includes(keyword))
|
||||
score += 1;
|
||||
if (currentDesc.includes(keyword) && postTitle.includes(keyword))
|
||||
score += 1;
|
||||
});
|
||||
|
||||
return { ...post, score };
|
||||
});
|
||||
|
||||
// Sort by score and return top posts
|
||||
return scoredPosts
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ score, ...post }) => {
|
||||
// Score used for sorting, removed from final result
|
||||
void score;
|
||||
return post;
|
||||
});
|
||||
};
|
||||
|
||||
const relatedArticles = getRelatedArticles(post, allPosts);
|
||||
|
||||
// Generate structured data for search engines
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: post.frontmatter.title,
|
||||
description: post.frontmatter.description,
|
||||
author: {
|
||||
"@type": "Person",
|
||||
name: post.frontmatter.author,
|
||||
},
|
||||
publisher: {
|
||||
"@type": "Organization",
|
||||
name: "CommunityRule",
|
||||
url: "https://communityrule.com",
|
||||
logo: {
|
||||
"@type": "ImageObject",
|
||||
url: "https://communityrule.com/assets/Logo.svg",
|
||||
},
|
||||
},
|
||||
datePublished: post.frontmatter.date,
|
||||
dateModified: post.frontmatter.date,
|
||||
mainEntityOfPage: {
|
||||
"@type": "WebPage",
|
||||
"@id": `https://communityrule.com/blog/${post.slug}`,
|
||||
},
|
||||
url: `https://communityrule.com/blog/${post.slug}`,
|
||||
articleSection: "Community Building",
|
||||
keywords: ["community", "governance", "decision making", "collaboration"],
|
||||
};
|
||||
|
||||
// Breadcrumb structured data
|
||||
const breadcrumbData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Home",
|
||||
item: "https://communityrule.com",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Blog",
|
||||
item: "https://communityrule.com/blog",
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 3,
|
||||
name: post.frontmatter.title,
|
||||
item: `https://communityrule.com/blog/${post.slug}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Get article-specific background color from frontmatter
|
||||
const getBackgroundColor = (post: BlogPost): string => {
|
||||
if (post.frontmatter?.background?.color) {
|
||||
return post.frontmatter.background.color;
|
||||
}
|
||||
return "#1F2937"; // Default fallback (dark gray)
|
||||
};
|
||||
|
||||
const backgroundColor = getBackgroundColor(post);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Structured Data */}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(structuredData),
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(breadcrumbData),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="min-h-screen relative overflow-hidden"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
{/* Content Banner */}
|
||||
<ContentBanner post={post} />
|
||||
|
||||
{/* Decorative Shapes */}
|
||||
{/* Right Side Shape (3/4 up the page) */}
|
||||
<div
|
||||
className="hidden md:block absolute top-1/4 right-0 pointer-events-none z-10"
|
||||
style={{ transform: "translateX(40%)" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.CONTENT_SHAPE_1)}
|
||||
alt=""
|
||||
className="w-auto h-auto max-w-none"
|
||||
style={{
|
||||
width: "clamp(120px, 15vw, 200px)",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Left Side Shape (3/4 down the page) */}
|
||||
<div
|
||||
className="hidden md:block absolute top-1/2 left-0 pointer-events-none z-10"
|
||||
style={{ transform: "translateX(-10%)" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.CONTENT_SHAPE_2)}
|
||||
alt=""
|
||||
className="w-auto h-auto max-w-none"
|
||||
style={{
|
||||
width: "clamp(100px, 12vw, 180px)",
|
||||
height: "auto",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<article className="p-[var(--spacing-scale-024)] sm:py-[var(--spacing-scale-032)]">
|
||||
{/* Article Content */}
|
||||
<div className="post-body -mt-[var(--spacing-scale-048)] text-[var(--color-content-inverse-primary)] text-[16px] leading-[24px] sm:text-[18px] sm:leading-[130%] lg:text-[24px] lg:leading-[32px] xl:text-[32px] xl:leading-[40px] sm:mx-auto sm:max-w-[390px] md:max-w-[472px] lg:max-w-[700px] xl:max-w-[904px]">
|
||||
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{/* Related Articles Section */}
|
||||
<RelatedArticles
|
||||
relatedPosts={relatedArticles}
|
||||
currentPostSlug={post.slug}
|
||||
slugOrder={slugOrder}
|
||||
/>
|
||||
|
||||
{/* Ask Organizer Section */}
|
||||
<AskOrganizer {...askOrganizerData} variant="inverse" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* Blog post body styling with semantic spacing */
|
||||
.post-body p {
|
||||
/* Scales with font size - uses logical properties for better writing mode support */
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
/* Extra blank lines from markdown -> visible gaps that scale with font size */
|
||||
.post-body .md-gap {
|
||||
/* Each "extra blank line" is one em; scales with font size */
|
||||
block-size: calc(1em * var(--gap, 1));
|
||||
margin: 0; /* no extra margins around the gap */
|
||||
line-height: 1; /* prevent tall line-height from compounding */
|
||||
}
|
||||
|
||||
/* Heading rhythm for better typography */
|
||||
.post-body h1 {
|
||||
margin-block: 1.5em 0.6em;
|
||||
}
|
||||
.post-body h2 {
|
||||
margin-block: 1.4em 0.6em;
|
||||
}
|
||||
.post-body h3 {
|
||||
margin-block: 1.2em 0.5em;
|
||||
}
|
||||
.post-body h4 {
|
||||
margin-block: 1.1em 0.5em;
|
||||
}
|
||||
.post-body h5 {
|
||||
margin-block: 1em 0.4em;
|
||||
}
|
||||
.post-body h6 {
|
||||
margin-block: 1em 0.4em;
|
||||
}
|
||||
|
||||
/* Ensure line breaks are visible */
|
||||
.post-body br {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { getAllBlogPosts } from "../../../lib/content";
|
||||
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
openGraph: {
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
url: "https://communityrule.com/blog",
|
||||
siteName: "CommunityRule",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Blog - CommunityRule",
|
||||
description:
|
||||
"Learn about community governance, decision-making, and building successful organizations.",
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogPage() {
|
||||
const posts = getAllBlogPosts();
|
||||
|
||||
// Create slug order for consistent icon cycling
|
||||
const slugOrder = posts.map((post) => post.slug);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F4F3F1]">
|
||||
<main className="pt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-[var(--color-content-default-primary)] mb-4">
|
||||
Blog
|
||||
</h1>
|
||||
<p className="text-lg text-[var(--color-content-default-secondary)] max-w-2xl mx-auto">
|
||||
Learn about community governance, decision-making, and building
|
||||
successful organizations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{posts.map((post, index) => (
|
||||
<ContentThumbnailTemplate
|
||||
key={post.slug}
|
||||
post={post}
|
||||
slugOrder={slugOrder}
|
||||
variant={index % 2 === 0 ? "vertical" : "horizontal"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user