Skip to content

Commit 9adf1db

Browse files
committed
sitemap
2 parents 51c1fc9 + 4b8dee2 commit 9adf1db

File tree

16 files changed

+676
-447
lines changed

16 files changed

+676
-447
lines changed

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,8 @@ GOOGLE_CLIENT_SECRET="alot"
3838
# Not Necessary unless self-hosting
3939
RESEND_API_KEY="bongo cat"
4040

41-
NEXT_PUBLIC_API_URL="http://localhost:3001"
41+
NEXT_PUBLIC_API_URL="http://localhost:3001"
42+
43+
# Not Necessary unless using blog
44+
MARBLE_WORKSPACE_KEY=
45+
MARBLE_API_URL=https://api.marblecms.com
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { formatDate } from '@databuddy/shared';
2+
import {
3+
ArrowLeftIcon,
4+
CalendarIcon,
5+
ClockIcon,
6+
UserIcon,
7+
} from '@phosphor-icons/react/ssr';
8+
import type { Metadata } from 'next';
9+
import Image from 'next/image';
10+
import Link from 'next/link';
11+
import { notFound } from 'next/navigation';
12+
import { SITE_URL } from '@/app/util/constants';
13+
import { Footer } from '@/components/footer';
14+
import { Prose } from '@/components/prose';
15+
import { getPosts, getSinglePost } from '@/lib/blog-query';
16+
17+
const STRIP_HTML_REGEX = /<[^>]+>/g;
18+
const WORD_SPLIT_REGEX = /\s+/;
19+
20+
export const revalidate = 300;
21+
22+
export async function generateStaticParams() {
23+
const { posts } = await getPosts();
24+
return posts.map((post) => ({
25+
slug: post.slug,
26+
}));
27+
}
28+
29+
interface PageProps {
30+
params: Promise<{ slug: string }>;
31+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
32+
}
33+
34+
export async function generateMetadata({
35+
params,
36+
}: PageProps): Promise<Metadata> {
37+
const slug = (await params).slug;
38+
39+
const data = await getSinglePost(slug);
40+
41+
if (!data?.post) {
42+
return notFound();
43+
}
44+
45+
return {
46+
title: `${data.post.title} | Databuddy`,
47+
description: data.post.description,
48+
twitter: {
49+
title: `${data.post.title} | Databuddy`,
50+
description: data.post.description,
51+
card: 'summary_large_image',
52+
images: [
53+
{
54+
url: data.post.coverImage ?? `${SITE_URL}/og.webp`,
55+
width: '1200',
56+
height: '630',
57+
alt: data.post.title,
58+
},
59+
],
60+
},
61+
openGraph: {
62+
title: `${data.post.title} | Databuddy`,
63+
description: data.post.description,
64+
type: 'article',
65+
images: [
66+
{
67+
url: data.post.coverImage ?? `${SITE_URL}/og.webp`,
68+
width: '1200',
69+
height: '630',
70+
alt: data.post.title,
71+
},
72+
],
73+
publishedTime: new Date(data.post.publishedAt).toISOString(),
74+
authors: [
75+
...data.post.authors.map((author: { name: string }) => author.name),
76+
],
77+
},
78+
};
79+
}
80+
81+
export default async function PostPage({
82+
params,
83+
}: {
84+
params: Promise<{ slug: string }>;
85+
}) {
86+
const slug = (await params).slug;
87+
88+
const { post } = await getSinglePost(slug);
89+
90+
if (!post) {
91+
return notFound();
92+
}
93+
94+
const estimateReadingTime = (htmlContent: string): string => {
95+
const text = htmlContent.replace(STRIP_HTML_REGEX, ' ');
96+
const words = text.trim().split(WORD_SPLIT_REGEX).filter(Boolean).length;
97+
const minutes = Math.max(1, Math.ceil(words / 200));
98+
return `${minutes} min read`;
99+
};
100+
101+
const readingTime = estimateReadingTime(post.content);
102+
103+
return (
104+
<>
105+
<div className="mx-auto w-full max-w-3xl px-4 pt-10 sm:px-6 sm:pt-12 lg:px-8">
106+
<div className="mb-4">
107+
<Link
108+
aria-label="Back to blog"
109+
className="inline-flex items-center gap-2 text-muted-foreground text-xs hover:text-foreground"
110+
href="/blog"
111+
>
112+
<ArrowLeftIcon className="h-3.5 w-3.5" weight="fill" />
113+
Back to blog
114+
</Link>
115+
</div>
116+
{/* Title */}
117+
<h1 className="mb-3 text-balance font-semibold text-3xl leading-tight tracking-tight sm:text-4xl md:text-5xl">
118+
{post.title}
119+
</h1>
120+
121+
{/* Metadata */}
122+
<div className="mb-4 flex flex-wrap items-center gap-4 text-muted-foreground text-xs sm:text-sm">
123+
<div className="flex items-center gap-2">
124+
<UserIcon className="h-4 w-4" weight="duotone" />
125+
<div className="-space-x-2 flex">
126+
{post.authors.slice(0, 3).map((author) => (
127+
<Image
128+
alt={author.name}
129+
className="h-6 w-6 rounded border-2 border-background"
130+
height={24}
131+
key={author.id}
132+
src={author.image}
133+
width={24}
134+
/>
135+
))}
136+
</div>
137+
<span>
138+
{post.authors.length === 1
139+
? post.authors[0].name
140+
: `${post.authors[0].name} +${post.authors.length - 1}`}
141+
</span>
142+
</div>
143+
<div className="flex items-center gap-2">
144+
<CalendarIcon className="h-4 w-4" weight="duotone" />
145+
<span>{formatDate(post.publishedAt)}</span>
146+
</div>
147+
<div className="flex items-center gap-2">
148+
<ClockIcon className="h-4 w-4" weight="duotone" />
149+
<span>{readingTime}</span>
150+
</div>
151+
</div>
152+
153+
{/* TL;DR */}
154+
{post.description && (
155+
<div className="mb-6 rounded border border-border bg-card/50 p-4">
156+
<div className="mb-1 font-semibold text-foreground/70 text-xs tracking-wide">
157+
TL;DR
158+
</div>
159+
<p className="text-muted-foreground text-sm">{post.description}</p>
160+
</div>
161+
)}
162+
163+
{/* Cover Image */}
164+
{post.coverImage && (
165+
<div className="mb-6 overflow-hidden rounded">
166+
<Image
167+
alt={post.title}
168+
className="aspect-video w-full object-cover"
169+
height={630}
170+
src={post.coverImage}
171+
width={1200}
172+
/>
173+
</div>
174+
)}
175+
176+
{/* Content */}
177+
<Prose html={post.content} />
178+
</div>
179+
<div className="mt-8" />
180+
<Footer />
181+
</>
182+
);
183+
}

0 commit comments

Comments
 (0)