diff --git a/apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx b/apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx new file mode 100644 index 00000000..dd0c2768 --- /dev/null +++ b/apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx @@ -0,0 +1,26 @@ +"use client"; + +import Image from "next/image"; +import { PhotoProvider, PhotoView } from "react-photo-view"; +import "react-photo-view/dist/react-photo-view.css"; + +interface ZoomableImageProps { + src: string; + alt: string; + className?: string; +} + +export function ZoomableImage({ src, alt, className }: ZoomableImageProps) { + return ( + + + {alt} + + + ); +} 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..3984cdbf --- /dev/null +++ b/apps/website/app/[locale]/blog/[slug]/page.tsx @@ -0,0 +1,303 @@ +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"; +import type { DetailedHTMLProps, HTMLAttributes } from "react"; +import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import remarkGfm from "remark-gfm"; +import { ZoomableImage } from "./components/ZoomableImage"; + +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); + const allPosts = await getPosts(); + + // Get related posts (excluding current post) + const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); // Show only 3 related posts + + if (!post) { + notFound(); + } + + const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { + year: "numeric", + month: "long", + day: "numeric", + }); + + const components: Partial = { + h1: ({ node, ...props }) => ( +

+ ), + h2: ({ node, ...props }) => ( +

+ ), + h3: ({ node, ...props }) => ( +

+ ), + p: ({ node, ...props }) => ( +

+ ), + a: ({ node, href, ...props }) => ( + + ), + ul: ({ node, ...props }) => ( +