Skip to content

Commit e5c4961

Browse files
authored
GH-145 Add blog via strapi cms (#145)
* Add blog via strapi cms Signed-off-by: vLuckyyy <[email protected]> * Fix lint Signed-off-by: vLuckyyy <[email protected]> * fix build Signed-off-by: vLuckyyy <[email protected]> * Revalidate 60s Signed-off-by: vLuckyyy <[email protected]> * Improve ui/ux of blog card Signed-off-by: vLuckyyy <[email protected]> * Set revalidate time to 5s, after consultation. Signed-off-by: vLuckyyy <[email protected]> * Fix blog padding on mobile Signed-off-by: vLuckyyy <[email protected]> * Move to strapi v5 Signed-off-by: vLuckyyy <[email protected]> * Fix blog card Signed-off-by: vLuckyyy <[email protected]> * Fully migrate to strapi v5 Signed-off-by: vLuckyyy <[email protected]> * Add image alt Signed-off-by: vLuckyyy <[email protected]> --------- Signed-off-by: vLuckyyy <[email protected]>
1 parent b216a3e commit e5c4961

File tree

20 files changed

+1143
-12
lines changed

20 files changed

+1143
-12
lines changed

app/api/project/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse } from "next/server";
22

3-
export const revalidate = 3600;
3+
export const revalidate = 5;
44

55
export async function GET(_request: Request) {
66
try {
@@ -20,7 +20,7 @@ export async function GET(_request: Request) {
2020

2121
return NextResponse.json(body, {
2222
headers: {
23-
"Cache-Control": "public, max-age=60, stale-while-revalidate=3600",
23+
"Cache-Control": "public, max-age=5, stale-while-revalidate=5",
2424
},
2525
});
2626
} catch (error) {

app/api/team/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextResponse } from "next/server";
22

3-
export const revalidate = 3600;
3+
export const revalidate = 5;
44

55
export async function GET(_request: Request) {
66
try {
@@ -20,7 +20,7 @@ export async function GET(_request: Request) {
2020

2121
return NextResponse.json(body, {
2222
headers: {
23-
"Cache-Control": "public, max-age=60, stale-while-revalidate=3600",
23+
"Cache-Control": "public, max-age=5, stale-while-revalidate=5",
2424
},
2525
});
2626
} catch (error) {

app/author/[slug]/page.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import Image from "next/image";
2+
import { notFound } from "next/navigation";
3+
4+
import { AnimatedSection, AnimatedContainer, AnimatedElement } from "@/components/animations";
5+
import BlogPostCard from "@/components/blog/BlogPostCard";
6+
import { Pagination } from "@/components/ui/pagination";
7+
import { getAuthorBySlug, getBlogPostsByAuthor } from "@/lib/strapi";
8+
import { getImageUrl } from "@/lib/utils";
9+
10+
export async function generateMetadata(props: { params: Promise<{ slug: string }> }) {
11+
const { params } = await props;
12+
const { slug } = await params;
13+
const author = await getAuthorBySlug(slug);
14+
if (!author) {
15+
return {
16+
title: "Author Not Found | EternalCode.pl",
17+
description: "This author does not exist on EternalCode.pl.",
18+
};
19+
}
20+
return {
21+
title: `${author.name} – Author | EternalCode.pl`,
22+
description: author.bio || `Read articles by ${author.name} on EternalCode.pl`,
23+
openGraph: {
24+
title: `${author.name} – Author | EternalCode.pl`,
25+
description: author.bio || `Read articles by ${author.name} on EternalCode.pl`,
26+
type: "profile",
27+
url: `https://eternalcode.pl/author/${author.slug}`,
28+
images: author.avatar?.url ? [getImageUrl(author.avatar.url)] : [],
29+
profile: {
30+
firstName: author.name.split(" ")[0],
31+
lastName: author.name.split(" ").slice(1).join(" ") || undefined,
32+
username: author.slug,
33+
},
34+
},
35+
twitter: {
36+
card: "summary",
37+
title: `${author.name} – Author | EternalCode.pl`,
38+
description: author.bio || `Read articles by ${author.name} on EternalCode.pl`,
39+
images: author.avatar?.url ? [getImageUrl(author.avatar.url)] : [],
40+
},
41+
alternates: {
42+
canonical: `https://eternalcode.pl/author/${author.slug}`,
43+
},
44+
};
45+
}
46+
47+
export default async function AuthorPage(props: { params: Promise<{ slug: string }>, searchParams: Promise<{ page?: string }> }) {
48+
const { params, searchParams } = await props;
49+
const { slug } = await params;
50+
const resolvedSearchParams = await searchParams;
51+
if (!slug) notFound();
52+
const author = await getAuthorBySlug(slug);
53+
if (!author) notFound();
54+
const posts = await getBlogPostsByAuthor(slug);
55+
const ITEMS_PER_PAGE = 6;
56+
const currentPage = Math.max(1, parseInt(resolvedSearchParams?.page || "1", 10));
57+
const totalPages = Math.ceil(posts.length / ITEMS_PER_PAGE);
58+
const paginatedPosts = posts.slice(
59+
(currentPage - 1) * ITEMS_PER_PAGE,
60+
currentPage * ITEMS_PER_PAGE
61+
);
62+
63+
return (
64+
<div className="min-h-screen bg-lightGray-100 dark:bg-gray-900 pt-40 pb-12">
65+
<div className="mx-auto max-w-screen-xl px-4">
66+
<div className="mx-auto max-w-screen-xl grid grid-cols-1 md:grid-cols-3 gap-12">
67+
{/* avatar, bio, email - STICKY */}
68+
<aside className="md:col-span-1">
69+
<AnimatedSection animationType="fadeLeft" className="sticky top-32 flex flex-col items-start">
70+
{author.avatar && author.avatar.url && (
71+
<Image
72+
src={getImageUrl(author.avatar.url)}
73+
alt={author.name}
74+
width={160}
75+
height={160}
76+
className="rounded-full border-4 border-white shadow-lg dark:border-gray-700 mb-6"
77+
/>
78+
)}
79+
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">{author.name}</h1>
80+
{author.bio && (
81+
<p className="mb-4 text-gray-600 dark:text-gray-300 text-center md:text-left">{author.bio}</p>
82+
)}
83+
{author.email && (
84+
<a href={`mailto:${author.email}`} className="text-blue-600 dark:text-blue-400 hover:underline mb-2">
85+
{author.email}
86+
</a>
87+
)}
88+
</AnimatedSection>
89+
</aside>
90+
91+
{/* articles */}
92+
<main className="md:col-span-2">
93+
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">Articles</h2>
94+
{posts.length > 0 ? (
95+
<>
96+
<AnimatedContainer as="div" staggerDelay={0.12} className="grid gap-8 md:grid-cols-2 mb-8">
97+
{paginatedPosts.map((post, i) => (
98+
<AnimatedElement key={post.documentId} animationType="fadeUp" delay={i * 0.05}>
99+
<BlogPostCard post={post} />
100+
</AnimatedElement>
101+
))}
102+
</AnimatedContainer>
103+
<Pagination
104+
currentPage={currentPage}
105+
totalPages={totalPages}
106+
totalItems={posts.length}
107+
itemsPerPage={ITEMS_PER_PAGE}
108+
slug={slug}
109+
className="mt-8"
110+
/>
111+
</>
112+
) : (
113+
<div className="text-gray-500 dark:text-gray-400 italic">No articles found for this author <span className="not-italic">(yet!)</span></div>
114+
)}
115+
</main>
116+
</div>
117+
</div>
118+
</div>
119+
);
120+
}

app/blog/[slug]/page.tsx

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import type { Metadata } from "next";
2+
import Image from "next/image";
3+
import Link from "next/link";
4+
import { notFound } from "next/navigation";
5+
6+
import { AnimatedSection } from "@/components/animations";
7+
import BlogPostContent from "@/components/blog/BlogPostContent";
8+
import { generateOgImageUrl } from "@/lib/og-utils";
9+
import { getBlogPost, StrapiTag } from "@/lib/strapi";
10+
11+
export const dynamic = "force-dynamic";
12+
export const revalidate = 5;
13+
14+
export async function generateStaticParams() {
15+
try {
16+
return [];
17+
} catch (error) {
18+
console.error("Error generating static params:", error);
19+
return [];
20+
}
21+
}
22+
23+
function getImageUrl(url: string) {
24+
if (!url) return '';
25+
if (url.startsWith('http')) return url;
26+
const base = process.env.NEXT_PUBLIC_ETERNALCODE_STRAPI_URL || '';
27+
return `${base}${url}`;
28+
}
29+
30+
function getTagsArray(tags: StrapiTag[] | { data: StrapiTag[] } | undefined): StrapiTag[] {
31+
if (!tags) return [];
32+
if (Array.isArray(tags)) return tags;
33+
if ('data' in tags && Array.isArray(tags.data)) return tags.data;
34+
return [];
35+
}
36+
37+
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
38+
try {
39+
const { slug } = await params;
40+
const post = await getBlogPost(slug);
41+
42+
if (!post) {
43+
return {
44+
title: "Post Not Found | EternalCode.pl",
45+
};
46+
}
47+
48+
const ogImageUrl = post.featuredImage?.url
49+
? getImageUrl(post.featuredImage.url)
50+
: generateOgImageUrl({
51+
title: post.title,
52+
subtitle: post.excerpt,
53+
});
54+
55+
const tagsArr = getTagsArray(post.tags);
56+
57+
return {
58+
title: `${post.title} | EternalCode.pl`,
59+
description: post.excerpt,
60+
keywords: tagsArr.map((tag: StrapiTag) => tag.name) || [],
61+
authors: [{ name: post.author?.name || "EternalCode Team" }],
62+
openGraph: {
63+
type: "article",
64+
locale: "en_US",
65+
url: `https://eternalcode.pl/blog/${post.slug}`,
66+
siteName: "EternalCode.pl",
67+
title: post.title,
68+
description: post.excerpt,
69+
images: [
70+
{
71+
url: ogImageUrl,
72+
width: 1200,
73+
height: 630,
74+
alt: post.title,
75+
},
76+
],
77+
publishedTime: post.publishedAt,
78+
modifiedTime: post.updatedAt,
79+
authors: [post.author?.name || "EternalCode Team"],
80+
tags: tagsArr.map((tag: StrapiTag) => tag.name) || [],
81+
},
82+
twitter: {
83+
card: "summary_large_image",
84+
site: "@eternalcode",
85+
creator: "@eternalcode",
86+
title: post.title,
87+
description: post.excerpt,
88+
images: [ogImageUrl],
89+
},
90+
alternates: {
91+
canonical: `https://eternalcode.pl/blog/${post.slug}`,
92+
},
93+
};
94+
} catch (error) {
95+
console.error("Error generating metadata:", error);
96+
return {
97+
title: "Post Not Found | EternalCode.pl",
98+
};
99+
}
100+
}
101+
102+
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
103+
try {
104+
const { slug } = await params;
105+
const post = await getBlogPost(slug);
106+
107+
if (!post) {
108+
notFound();
109+
}
110+
111+
const tagsArr = getTagsArray(post.tags);
112+
113+
return (
114+
<div className="min-h-screen bg-lightGray-100 dark:bg-gray-900">
115+
{/* Hero Section */}
116+
<AnimatedSection animationType="fadeDown" className="pt-40 md:pt-48 pb-0">
117+
<div className="mx-auto max-w-screen-xl px-4">
118+
<h1 className="mb-4 text-4xl md:text-5xl font-extrabold text-gray-900 dark:text-white text-left">
119+
{post.title}
120+
</h1>
121+
<p className="mb-4 text-lg text-gray-600 dark:text-gray-300 text-left">
122+
{post.excerpt}
123+
</p>
124+
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-4">
125+
{post.author && post.author.slug && (
126+
<Link href={`/author/${post.author.slug}`} className="flex items-center gap-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
127+
{post.author.avatar && (
128+
<Image
129+
src={getImageUrl(post.author.avatar.url)}
130+
alt={post.author.name}
131+
width={24}
132+
height={24}
133+
className="rounded-full"
134+
/>
135+
)}
136+
<span>By {post.author.name}</span>
137+
</Link>
138+
)}
139+
<span></span>
140+
<time dateTime={post.publishedAt}>
141+
{new Date(post.publishedAt).toLocaleDateString("en-US", {
142+
year: "numeric",
143+
month: "long",
144+
day: "numeric",
145+
})}
146+
</time>
147+
{post.readingTime && (
148+
<>
149+
<span></span>
150+
<span>{post.readingTime} min read</span>
151+
</>
152+
)}
153+
</div>
154+
{tagsArr.length > 0 && (
155+
<div className="flex flex-wrap gap-2 mb-6">
156+
{tagsArr.map((tag: StrapiTag) => (
157+
<span
158+
key={tag.documentId}
159+
className="rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
160+
>
161+
{tag.name}
162+
</span>
163+
))}
164+
</div>
165+
)}
166+
</div>
167+
</AnimatedSection>
168+
169+
{/* Blog Content */}
170+
<AnimatedSection animationType="fadeUp" className="py-16">
171+
<div className="mx-auto max-w-screen-xl px-4">
172+
<BlogPostContent content={post.content} />
173+
</div>
174+
</AnimatedSection>
175+
</div>
176+
);
177+
} catch (error) {
178+
console.error("Error fetching blog post:", error);
179+
notFound();
180+
}
181+
}

0 commit comments

Comments
 (0)