From 2e09e76e5049c9c9c421e4c0a0a8f2fe1a69e0fd Mon Sep 17 00:00:00 2001 From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com> Date: Thu, 27 Feb 2025 23:12:17 -0600 Subject: [PATCH 01/12] feat: add Ghost-powered blog with dynamic content and internationalization --- .../website/app/[locale]/blog/[slug]/page.tsx | 145 ++++++++++++++ apps/website/app/[locale]/blog/page.tsx | 95 +++++++++ .../app/[locale]/blog/tag/[tag]/page.tsx | 135 +++++++++++++ apps/website/components/Header.tsx | 2 + apps/website/components/blog/BlogCard.tsx | 72 +++++++ apps/website/components/navigation.tsx | 6 + apps/website/lib/ghost.ts | 127 ++++++++++++ apps/website/lib/types/ghost-content-api.d.ts | 44 ++++ apps/website/locales/en.json | 22 +- apps/website/locales/fr.json | 18 +- apps/website/locales/zh-Hans.json | 18 +- apps/website/next.config.js | 6 + apps/website/package.json | 2 + pnpm-lock.yaml | 188 ++++++++++++++++++ 14 files changed, 875 insertions(+), 5 deletions(-) create mode 100644 apps/website/app/[locale]/blog/[slug]/page.tsx create mode 100644 apps/website/app/[locale]/blog/page.tsx create mode 100644 apps/website/app/[locale]/blog/tag/[tag]/page.tsx create mode 100644 apps/website/components/blog/BlogCard.tsx create mode 100644 apps/website/components/navigation.tsx create mode 100644 apps/website/lib/ghost.ts create mode 100644 apps/website/lib/types/ghost-content-api.d.ts diff --git a/apps/website/app/[locale]/blog/[slug]/page.tsx b/apps/website/app/[locale]/blog/[slug]/page.tsx new file mode 100644 index 00000000..fe153bdc --- /dev/null +++ b/apps/website/app/[locale]/blog/[slug]/page.tsx @@ -0,0 +1,145 @@ +import { getPost, getPosts } from "@/lib/ghost"; +import type { Post } from "@/lib/ghost"; +import type { Metadata, ResolvingMetadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +type Props = { + params: { locale: string; slug: string }; +}; + +export async function generateMetadata( + { params }: Props, + parent: ResolvingMetadata, +): Promise { + const post = await getPost(params.slug); + + if (!post) { + return { + title: "Post Not Found", + }; + } + + return { + title: post.title, + description: post.custom_excerpt || post.excerpt, + openGraph: post.feature_image + ? { + images: [ + { + url: post.feature_image, + width: 1200, + height: 630, + alt: post.title, + }, + ], + } + : undefined, + }; +} + +export async function generateStaticParams() { + const posts = await getPosts(); + + return posts.map((post) => ({ + slug: post.slug, + })); +} + +export default async function BlogPostPage({ params }: Props) { + const { locale, slug } = params; + const t = await getTranslations({ locale, namespace: "blog" }); + const post = await getPost(slug); + + if (!post) { + notFound(); + } + + const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( +
+ + + + + {t("backToBlog")} + + +
+

{post.title}

+
+ {post.primary_author?.profile_image && ( +
+ {post.primary_author.name} +
+ )} +
+

+ {post.primary_author?.name || "Unknown Author"} +

+

+ {formattedDate} • {post.reading_time} min read +

+
+
+ {post.feature_image && ( +
+ {post.title} +
+ )} +
+ +
+ + {post.tags && post.tags.length > 0 && ( +
+

{t("tags")}

+
+ {post.tags.map((tag) => ( + + {tag.name} + + ))} +
+
+ )} +
+ ); +} diff --git a/apps/website/app/[locale]/blog/page.tsx b/apps/website/app/[locale]/blog/page.tsx new file mode 100644 index 00000000..51c31b19 --- /dev/null +++ b/apps/website/app/[locale]/blog/page.tsx @@ -0,0 +1,95 @@ +import { getPosts } from "@/lib/ghost"; +import type { Post } from "@/lib/ghost"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Image from "next/image"; +import Link from "next/link"; + +export const metadata: Metadata = { + title: "Blog | Dokploy", + description: "Latest news, updates, and articles from Dokploy", +}; + +export const revalidate = 3600; // Revalidate the data at most every hour + +export default async function BlogPage({ + params: { locale }, +}: { + params: { locale: string }; +}) { + const t = await getTranslations({ locale, namespace: "blog" }); + const posts = await getPosts(); + + return ( +
+

{t("title")}

+ + {posts.length === 0 ? ( +
+

+ {t("noPosts")} +

+
+ ) : ( +
+ {posts.map((post: Post) => ( + + ))} +
+ )} +
+ ); +} + +function BlogPostCard({ post, locale }: { post: Post; locale: string }) { + const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( + +
+ {post.feature_image && ( +
+ {post.title} +
+ )} +
+

+ {post.title} +

+

+ {formattedDate} • {post.reading_time} min read +

+

+ {post.custom_excerpt || post.excerpt} +

+
+ {post.primary_author?.profile_image && ( +
+ {post.primary_author.name} +
+ )} +
+

+ {post.primary_author?.name || "Unknown Author"} +

+
+
+
+
+ + ); +} diff --git a/apps/website/app/[locale]/blog/tag/[tag]/page.tsx b/apps/website/app/[locale]/blog/tag/[tag]/page.tsx new file mode 100644 index 00000000..4d66662c --- /dev/null +++ b/apps/website/app/[locale]/blog/tag/[tag]/page.tsx @@ -0,0 +1,135 @@ +import { getPostsByTag, getTags } from "@/lib/ghost"; +import type { Post } from "@/lib/ghost"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +type Props = { + params: { locale: string; tag: string }; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { tag, locale } = params; + const t = await getTranslations({ locale, namespace: "blog" }); + + return { + title: `${t("tagTitle", { tag })}`, + description: t("tagDescription", { tag }), + }; +} + +export async function generateStaticParams() { + const tags = await getTags(); + + return tags.map((tag: { slug: string }) => ({ + tag: tag.slug, + })); +} + +export default async function TagPage({ params }: Props) { + const { locale, tag } = params; + const t = await getTranslations({ locale, namespace: "blog" }); + const posts = await getPostsByTag(tag); + + if (!posts || posts.length === 0) { + notFound(); + } + + // Get the tag name from the first post + const tagName = + posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag; + + return ( +
+ + + + + {t("backToBlog")} + + +
+

+ {t("postsTaggedWith")}{" "} + "{tagName}" +

+

+ {t("foundPosts", { count: posts.length })} +

+
+ +
+ {posts.map((post: Post) => ( + + ))} +
+
+ ); +} + +function BlogPostCard({ post, locale }: { post: Post; locale: string }) { + const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + return ( + +
+ {post.feature_image && ( +
+ {post.title} +
+ )} +
+

+ {post.title} +

+

+ {formattedDate} • {post.reading_time} min read +

+

+ {post.custom_excerpt || post.excerpt} +

+
+ {post.primary_author?.profile_image && ( +
+ {post.primary_author.name} +
+ )} +
+

+ {post.primary_author?.name || "Unknown Author"} +

+
+
+
+
+ + ); +} diff --git a/apps/website/components/Header.tsx b/apps/website/components/Header.tsx index 7663c2ba..b4d9e046 100644 --- a/apps/website/components/Header.tsx +++ b/apps/website/components/Header.tsx @@ -127,6 +127,7 @@ function MobileNavigation() { {t("navigation.docs")} + {t("navigation.blog")}