Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<PhotoProvider>
<PhotoView src={src}>
<Image
src={src}
alt={alt}
fill
className={`object-cover cursor-zoom-in ${className || ""}`}
/>
</PhotoView>
</PhotoProvider>
);
}
303 changes: 303 additions & 0 deletions apps/website/app/[locale]/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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<Components> = {
h1: ({ node, ...props }) => (
<h1 className="text-3xl text-primary font-bold mt-8 mb-4" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-2xl text-primary/90 font-bold mt-6 mb-3" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-xl text-primary/90 font-bold mt-4 mb-2" {...props} />
),
p: ({ node, ...props }) => (
<p
className="text-base text-muted-foreground leading-relaxed mb-4"
{...props}
/>
),
a: ({ node, href, ...props }) => (
<a
href={href}
className="text-blue-500 hover:text-blue-500/80 transition-colors"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-inside space-y-2 mb-4" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-inside space-y-2 mb-4" {...props} />
),
li: ({ node, ...props }) => (
<li className="text-base leading-relaxed" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote
className="border-l-4 border-primary pl-4 py-2 my-4 bg-muted/50"
{...props}
/>
),
table: ({ node, ...props }) => (
<div className="my-6 w-full overflow-x-auto border rounded-lg">
<table className="w-full border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => (
<thead className="bg-muted border-b border-border" {...props} />
),
tbody: ({ node, ...props }) => (
<tbody className="divide-y divide-border" {...props} />
),
tr: ({ node, ...props }) => (
<tr className="transition-colors hover:bg-muted/50" {...props} />
),
th: ({ node, ...props }) => (
<th className="p-4 text-left font-semibold" {...props} />
),
td: ({ node, ...props }) => (
<td className="p-4 text-muted-foreground" {...props} />
),
img: ({ node, src, alt }) => (
<div className="relative w-full h-64 my-6 rounded-lg overflow-hidden">
{src && <ZoomableImage src={src} alt={alt || ""} />}
</div>
),
};

return (
<article className="container mx-auto px-4 py-12 max-w-5xl">
<Link
href="/blog"
className="inline-flex items-center mb-8 text-primary hover:text-primary/80 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
clipRule="evenodd"
/>
</svg>
{t("backToBlog")}
</Link>

<div className=" rounded-lg p-8 shadow-lg border border-border">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center mb-6">
{post.primary_author?.profile_image && (
<div className="relative h-12 w-12 rounded-full overflow-hidden mr-4">
{post.primary_author.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer transition-opacity hover:opacity-90"
>
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
</a>
) : (
<Image
src={post.primary_author.profile_image}
alt={post.primary_author.name}
fill
className="object-cover"
/>
)}
</div>
)}
<div>
<p className="font-medium">
{post.primary_author?.twitter ? (
<a
href={`https://twitter.com/${post.primary_author.twitter}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{post.primary_author.name || "Unknown Author"}
</a>
) : (
post.primary_author?.name || "Unknown Author"
)}
</p>
<p className="text-sm text-muted-foreground">
{formattedDate} • {post.reading_time} min read
</p>
</div>
</div>
{post.feature_image && (
<div className="relative w-full h-[400px] mb-8">
<ZoomableImage
src={post.feature_image}
alt={post.title}
className="rounded-lg"
/>
</div>
)}
</header>

<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={components}
>
{post.html}
</ReactMarkdown>
</div>

{post.tags && post.tags.length > 0 && (
<div className="mt-12 pt-6 border-t border-border">
<h2 className="text-xl font-semibold mb-4">{t("tags")}</h2>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag.id}
href={`/blog/tag/${tag.slug}`}
className="px-4 py-2 bg-muted hover:bg-muted/80 rounded-full text-sm transition-colors"
>
{tag.name}
</Link>
))}
</div>
</div>
)}
</div>

{relatedPosts.length > 0 && (
<div className="mt-12">
<h2 className="text-2xl font-bold mb-6">{t("relatedPosts")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{relatedPosts.map((relatedPost) => {
const relatedPostDate = new Date(
relatedPost.published_at,
).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});

return (
<Link
key={relatedPost.id}
href={`/blog/${relatedPost.slug}`}
className="group"
>
<div className="bg-card rounded-lg overflow-hidden h-full shadow-lg transition-all duration-300 hover:shadow-xl border border-border">
{relatedPost.feature_image && (
<div className="relative h-48 w-full">
<Image
src={relatedPost.feature_image}
alt={relatedPost.title}
fill
className="object-cover"
/>
</div>
)}
<div className="p-6">
<h3 className="text-lg font-semibold mb-2 group-hover:text-primary transition-colors line-clamp-2">
{relatedPost.title}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{relatedPostDate} • {relatedPost.reading_time} min read
</p>
<p className="text-muted-foreground line-clamp-2">
{relatedPost.excerpt}
</p>
</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</article>
);
}
Loading
Loading