mirror of
https://github.com/Dokploy/website.git
synced 2026-06-15 20:25:25 +02:00
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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",
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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",
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user