feat: implement blog page structure with post listing, filtering, and individual post views; add components for code highlighting, table of contents, and zoomable images

This commit is contained in:
Mauricio Siu
2025-11-05 01:12:31 -06:00
parent b51c6a8f58
commit 4d525715e7
12 changed files with 52 additions and 193 deletions

View File

@@ -1,67 +0,0 @@
import { getPost } from "@/lib/ghost";
import { generateOGImage } from "@/lib/og-image";
import type { NextRequest } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: { locale: string } },
) {
try {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
console.log(
"Generating OG image for slug:",
slug,
"locale:",
params.locale,
);
if (!slug) {
console.error("Missing slug parameter");
return new Response("Missing slug parameter", { status: 400 });
}
const post = await getPost(slug);
if (!post) {
console.error("Post not found for slug:", slug);
return new Response("Post not found", { status: 404 });
}
console.log("Found post:", post.title);
const formattedDate = new Date(post.published_at).toLocaleDateString(
params.locale,
{
year: "numeric",
month: "long",
day: "numeric",
},
);
const ogImage = await generateOGImage({
title: post.title,
author: post.primary_author
? {
name: post.primary_author.name,
image: post.primary_author.profile_image || undefined,
}
: undefined,
date: formattedDate,
readingTime: post.reading_time,
});
console.log("Successfully generated OG image");
return new Response(ogImage, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.error("Error generating OG image:", error);
return new Response(`Error generating image: ${error}`, { status: 500 });
}
}

View File

@@ -18,9 +18,7 @@ interface CodeBlockProps {
async function formatCode(code: string, lang: string) {
try {
let parser: string;
let plugins = [];
// Select parser and plugins based on language
let plugins = [] as any[];
switch (lang.toLowerCase()) {
case "yaml":
case "yml":
@@ -35,12 +33,8 @@ async function formatCode(code: string, lang: string) {
plugins = [babel, estree];
break;
default:
// For unsupported languages, return the original code
return code;
}
console.log(`Formatting ${lang} with parser:`, parser);
const formatted = await prettier.format(code, {
parser,
plugins,
@@ -50,12 +44,10 @@ async function formatCode(code: string, lang: string) {
useTabs: false,
printWidth: 120,
});
console.log("Formatted code:", formatted);
return formatted;
} catch (error) {
console.error("Error formatting code:", error);
return code; // Return original code if there's an error
return code;
}
}
@@ -66,22 +58,15 @@ export function CodeBlock({ code, lang, initial }: CodeBlockProps) {
useLayoutEffect(() => {
async function formatAndHighlight() {
try {
console.log("Original code:", code);
console.log("Language:", lang);
const formatted = await formatCode(code, lang);
setFormattedCode(formatted);
// Then highlight the formatted code
const highlighted = await highlight(formatted, lang);
setNodes(highlighted);
} catch (error) {
console.error("Error in formatAndHighlight:", error);
// If formatting fails, try to highlight the original code
const highlighted = await highlight(code, lang);
setNodes(highlighted);
}
}
void formatAndHighlight();
}, [code, lang]);

View File

@@ -29,15 +29,10 @@ function LinkIcon() {
export function H1({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const id = slugify(children?.toString() || "", { lower: true, strict: true });
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h1
id={id}
@@ -53,15 +48,10 @@ export function H1({ children, ...props }: HeadingProps) {
export function H2({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const id = slugify(children?.toString() || "", { lower: true, strict: true });
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h2
id={id}
@@ -77,15 +67,10 @@ export function H2({ children, ...props }: HeadingProps) {
export function H3({ children, ...props }: HeadingProps) {
const router = useRouter();
const id = slugify(children?.toString() || "", {
lower: true,
strict: true,
});
const id = slugify(children?.toString() || "", { lower: true, strict: true });
const handleClick = () => {
router.push(`#${id}`);
};
return (
<h3
id={id}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
interface Heading {
id: string;
text: string;
@@ -19,7 +20,6 @@ export function TableOfContents() {
text: element.textContent || "",
level: Number(element.tagName.charAt(1)),
}));
setHeadings(elements);
const observer = new IntersectionObserver(
@@ -35,9 +35,7 @@ export function TableOfContents() {
for (const { id } of elements) {
const element = document.getElementById(id);
if (element) {
observer.observe(element);
}
if (element) observer.observe(element);
}
return () => observer.disconnect();
@@ -48,31 +46,25 @@ export function TableOfContents() {
<p className="font-medium mb-4">Table of Contents</p>
<ul className="space-y-2">
{headings.length > 0 ? (
<>
{headings.map((heading) => (
<li
key={heading.id}
style={{ paddingLeft: `${(heading.level - 1) * 1}rem` }}
headings.map((heading) => (
<li
key={heading.id}
style={{ paddingLeft: `${(heading.level - 1) * 1}rem` }}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
document
.getElementById(heading.id)
?.scrollIntoView({ behavior: "smooth" });
}}
className={`hover:text-primary transition-colors block ${activeId === heading.id ? "text-primary font-medium" : "text-muted-foreground"}`}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(heading.id)?.scrollIntoView({
behavior: "smooth",
});
}}
className={`hover:text-primary transition-colors block ${
activeId === heading.id
? "text-primary font-medium"
: "text-muted-foreground"
}`}
>
{heading.text}
</a>
</li>
))}
</>
{heading.text}
</a>
</li>
))
) : (
<li>
<p className="text-muted-foreground">No headings found</p>

View File

@@ -3,6 +3,7 @@
import { cn } from "@/lib/utils";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
interface ZoomableImageProps {
src: string;
alt: string;

View File

@@ -10,10 +10,5 @@ export async function highlight(code: string, lang: BundledLanguage) {
lang,
theme: "houston",
});
return toJsxRuntime(out, {
Fragment,
jsx,
jsxs,
}) as JSX.Element;
return toJsxRuntime(out, { Fragment, jsx, jsxs }) as JSX.Element;
}

View File

@@ -1,6 +1,5 @@
import { getPost, getPosts } 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";
@@ -18,15 +17,17 @@ import { CodeBlock } from "./components/CodeBlock";
import { H1, H2, H3 } from "./components/Headings";
import { TableOfContents } from "./components/TableOfContents";
import { ZoomableImage } from "./components/ZoomableImage";
import { useTranslations } from "@/lib/intl";
type Props = {
params: { locale: string; slug: string };
params: { slug: string };
};
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const { locale, slug } = await params;
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
@@ -36,7 +37,7 @@ export async function generateMetadata(
}
const ogUrl = new URL(
`/${locale}/api/og`,
`/api/og`,
process.env.NODE_ENV === "production"
? "https://dokploy.com"
: "http://localhost:3000",
@@ -69,51 +70,30 @@ export async function generateMetadata(
};
}
// export async function generateStaticParams() {
// const posts = await getPosts();
// const locales = ["en", "fr", "es", "zh-Hans"];
// return posts.flatMap((post) =>
// locales.map((locale) => ({
// locale,
// slug: post.slug,
// })),
// );
// }
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const t = await getTranslations("blog");
const t = useTranslations("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
const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3);
if (!post) {
notFound();
}
// Limpiar HTML antes de convertir a Markdown
const cleanHtml = (html: string) => {
// Crear un DOM temporal para limpiar el HTML
if (typeof window !== "undefined") {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Remover scripts JSON-LD y otros scripts
const scripts = doc.querySelectorAll(
'script[type="application/ld+json"], script',
);
scripts.forEach((script) => script.remove());
// Remover otros elementos no deseados
const unwantedElements = doc.querySelectorAll("style, meta, link");
unwantedElements.forEach((el) => el.remove());
return doc.body.innerHTML;
} else {
// Fallback para servidor - usar regex para limpiar
return html
.replace(
/<script[^>]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/gi,
@@ -126,7 +106,6 @@ export default async function BlogPostPage({ params }: Props) {
}
};
// Convertir HTML a Markdown
const turndownService = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
@@ -219,8 +198,6 @@ export default async function BlogPostPage({ params }: Props) {
children,
inline,
}: { className: string; children: React.ReactNode; inline: boolean }) => {
console.log(className, children, inline);
// Si es código inline (no tiene className con language-*), renderizar como span
if (inline || !className || !/language-(\w+)/.test(className)) {
return (
<code className="px-1.5 py-0.5 bg-muted text-sm rounded font-mono text-foreground">
@@ -228,8 +205,6 @@ export default async function BlogPostPage({ params }: Props) {
</code>
);
}
// Si es un bloque de código, usar CodeBlock
const match = /language-(\w+)/.exec(className);
return (
<CodeBlock

View File

@@ -3,14 +3,14 @@
import type { Post } from "@/lib/ghost";
import Link from "next/link";
import { useRouter } from "next/navigation";
interface BlogPostCardProps {
post: Post;
locale: string;
}
export function BlogPostCard({ post, locale }: BlogPostCardProps) {
export function BlogPostCard({ post }: BlogPostCardProps) {
const router = useRouter();
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
const formattedDate = new Date(post.published_at).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",

View File

@@ -2,10 +2,11 @@ import { getPosts, getTags } from "@/lib/ghost";
import type { Post } from "@/lib/ghost";
import { RssIcon } from "lucide-react";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { BlogPostCard } from "./components/BlogPostCard";
import { SearchAndFilter } from "./components/SearchAndFilter";
import { useTranslations } from "@/lib/intl";
interface Tag {
id: string;
name: string;
@@ -18,15 +19,12 @@ export const metadata: Metadata = {
};
export default async function BlogPage({
params,
searchParams,
}: {
params: { locale: string };
searchParams: { [key: string]: string | string[] | undefined };
}) {
const { locale } = await params;
const searchParams2 = await searchParams;
const t = await getTranslations("blog");
const t = useTranslations("blog");
const posts = await getPosts();
const tags = (await getTags()) as Tag[];
const search =
@@ -80,7 +78,7 @@ export default async function BlogPage({
) : (
<div className="space-y-8">
{filteredPosts.map((post: Post) => (
<BlogPostCard key={post.id} post={post} locale={locale} />
<BlogPostCard key={post.id} post={post} />
))}
</div>
)}

View File

@@ -1,18 +1,18 @@
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";
import { useTranslations } from "@/lib/intl";
type Props = {
params: { locale: string; tag: string };
params: { tag: string };
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { tag } = await params;
const t = await getTranslations("blog");
const t = useTranslations("blog");
return {
title: `${t("tagTitle", { tag })}`,
@@ -22,22 +22,18 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export async function generateStaticParams() {
const tags = await getTags();
return tags.map((tag: { slug: string }) => ({
tag: tag.slug,
}));
return tags.map((tag: { slug: string }) => ({ tag: tag.slug }));
}
export default async function TagPage({ params }: Props) {
const { tag } = await params;
const t = await getTranslations("blog");
const t = useTranslations("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;
@@ -74,15 +70,15 @@ export default async function TagPage({ params }: Props) {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post: Post) => (
<BlogPostCard key={post.id} post={post} locale={locale} />
<BlogPostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
function BlogPostCard({ post, locale }: { post: Post; locale: string }) {
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
function BlogPostCard({ post }: { post: Post }) {
const formattedDate = new Date(post.published_at).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",

View File

@@ -4,11 +4,10 @@ import Link from "next/link";
interface BlogCardProps {
post: Post;
locale: string;
}
export function BlogCard({ post, locale }: BlogCardProps) {
const formattedDate = new Date(post.published_at).toLocaleDateString(locale, {
export function BlogCard({ post }: BlogCardProps) {
const formattedDate = new Date(post.published_at).toLocaleDateString("en", {
year: "numeric",
month: "long",
day: "numeric",
@@ -37,7 +36,7 @@ export function BlogCard({ post, locale }: BlogCardProps) {
{post.primary_tag.name}
</p>
)}
<Link href={`/${locale}/blog/${post.slug}`} className="mt-2 block">
<Link href={`/blog/${post.slug}`} className="mt-2 block">
<h3 className="text-xl font-semibold text-gray-900">
{post.title}
</h3>