Organize app using Next.js route groups

This commit is contained in:
adilallo
2026-02-06 21:59:43 -07:00
parent aa7364769e
commit 51990ca149
11 changed files with 28 additions and 28 deletions
+320
View File
@@ -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>
</>
);
}
+38
View File
@@ -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;
}
+59
View File
@@ -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>
);
}
+70
View File
@@ -0,0 +1,70 @@
import messages from "../../../messages/en/index";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import ContentThumbnailTemplate from "../../../components/content/ContentThumbnailTemplate";
import ContentLockup from "../../../components/type/ContentLockup";
import AskOrganizer from "../../../components/sections/AskOrganizer";
import { getAllBlogPosts } from "../../../lib/content";
export default function LearnPage() {
// Get real blog posts from the content system
const allPosts = getAllBlogPosts();
// Use direct message access for server components
const t = (key: string) => getTranslation(messages, key);
const contentLockupData = {
title: t("pages.learn.contentLockup.title"),
subtitle: t("pages.learn.contentLockup.subtitle"),
variant: "learn" as const,
alignment: "left" as const,
};
const askOrganizerData = {
title: t("pages.learn.askOrganizer.title"),
subtitle: t("pages.learn.askOrganizer.subtitle"),
description: t("pages.learn.askOrganizer.description"),
buttonText: t("pages.learn.askOrganizer.buttonText"),
buttonHref: t("pages.learn.askOrganizer.buttonHref"),
variant: "centered" as const,
};
return (
<div className="min-h-screen bg-[var(--color-surface-default-primary)]">
<ContentLockup {...contentLockupData} />
{/* Horizontal list (below smd) */}
<div className="smd:hidden sm:pt-[var(--spacing-scale-024)] sm:pb-[var(--spacing-scale-024)] sm:px-[var(--spacing-scale-020)] space-y-[var(--spacing-scale-002)] sm:space-y-[var(--spacing-scale-008)]">
{allPosts.slice(0, 3).map((post, index) => (
<ContentThumbnailTemplate
key={`${post.slug}-${index}-${
post.frontmatter.thumbnail?.horizontal || "default"
}`}
post={post}
variant="horizontal"
/>
))}
</div>
{/* smd and up: 2x3 grid of vertical thumbnails, repeat posts as needed */}
<div className="hidden smd:grid smd:grid-cols-2 xmd:grid-cols-3 lg:grid-cols-3 lg2:grid-cols-4 xl:grid-cols-5 smd:gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-016)] xmd:gap-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-016)] lg2:gap-x-[var(--spacing-scale-016)] lg2:gap-y-[var(--spacing-scale-024)] xl:gap-x-[var(--spacing-scale-016)] xl:gap-y-[var(--spacing-scale-016)] smd:pt-[var(--spacing-scale-024)] smd:pb-[var(--spacing-scale-024)] smd:px-[var(--spacing-scale-020)] md:px-[var(--spacing-scale-032)] lg:pt-[var(--spacing-scale-032)] lg:pb-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)]">
{Array.from({ length: 16 }).map((_, i) => {
const post = allPosts[i % allPosts.length];
return (
<ContentThumbnailTemplate
key={`grid-${post.slug}-${i}-${
post.frontmatter.thumbnail?.vertical || "default"
}`}
post={post}
variant="vertical"
className={`${i >= 6 ? "hidden lg2:block" : ""} ${
i >= 10 ? "xl:hidden" : ""
}`}
/>
);
})}
</div>
<AskOrganizer {...askOrganizerData} />
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import dynamic from "next/dynamic";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[200px]" />
),
ssr: true,
});
const NumberedCards = dynamic(() => import("../components/sections/NumberedCards"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[300px]" />
),
ssr: true,
});
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
});
const FeatureGrid = dynamic(() => import("../components/sections/FeatureGrid"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[500px]" />
),
ssr: true,
});
const QuoteBlock = dynamic(() => import("../components/sections/QuoteBlock"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[300px]" />
),
ssr: true,
});
export default function Page() {
// Use direct message access for server components
// This maintains type safety without requiring external i18n libraries
const t = (key: string) => getTranslation(messages, key);
const heroBannerData = {
title: t("pages.home.heroBanner.title"),
subtitle: t("pages.home.heroBanner.subtitle"),
description: t("pages.home.heroBanner.description"),
ctaText: t("pages.home.heroBanner.ctaText"),
ctaHref: t("pages.home.heroBanner.ctaHref"),
};
const numberedCardsData = {
title: t("pages.home.numberedCards.title"),
subtitle: t("pages.home.numberedCards.subtitle"),
cards: [
{
text: t("pages.home.numberedCards.cards.card1.text"),
iconShape: "blob",
iconColor: "green",
},
{
text: t("pages.home.numberedCards.cards.card2.text"),
iconShape: "gear",
iconColor: "purple",
},
{
text: t("pages.home.numberedCards.cards.card3.text"),
iconShape: "star",
iconColor: "orange",
},
],
};
const featureGridData = {
title: t("pages.home.featureGrid.title"),
subtitle: t("pages.home.featureGrid.subtitle"),
};
const askOrganizerData = {
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
buttonHref: t("pages.home.askOrganizer.buttonHref"),
};
return (
<div>
<HeroBanner {...heroBannerData} />
<LogoWall />
<NumberedCards {...numberedCardsData} />
<RuleStack />
<FeatureGrid {...featureGridData} />
<QuoteBlock />
<AskOrganizer {...askOrganizerData} />
</div>
);
}