Skip to content

Commit 3a9bf94

Browse files
author
marcus
committed
feat(portfolio): serve blog content directly from separate Sanity project
1 parent 7bb5ae0 commit 3a9bf94

11 files changed

Lines changed: 484 additions & 40 deletions

File tree

.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66

77
NEXT_PUBLIC_SANITY_PROJECT_ID="mlo2lep5"
88
NEXT_PUBLIC_SANITY_DATASET="production"
9+
10+
NEXT_PUBLIC_BLOG_SANITY_PROJECT_ID="s9thr270"
11+
NEXT_PUBLIC_BLOG_SANITY_DATASET="production"

app/(app)/blog/[slug]/page.tsx

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { PortableText, type PortableTextComponents } from "@portabletext/react";
2+
import { Button } from "@/components/ui/button";
3+
import { getBlogBySlug, getBlogSlugs } from "@/lib/blog";
4+
import { formatDate, slugify } from "@/lib/utils";
5+
import { blogDataset, blogProjectId } from "@/sanity/blog-env";
6+
import { urlForBlogImage } from "@/sanity/blog-image";
7+
import { IBlogArticle, IBlogHeading } from "@/types/blog";
8+
import Image from "next/image";
9+
import Link from "next/link";
10+
import { notFound } from "next/navigation";
11+
12+
export const revalidate = 30;
13+
14+
function getHeadingText(heading: IBlogHeading) {
15+
return (
16+
heading.children
17+
?.map((child) => child.text || "")
18+
.join("")
19+
.trim() || ""
20+
);
21+
}
22+
23+
function getAuthorName(author: IBlogArticle["author"]) {
24+
return author?.name || "Marcus Nguyen";
25+
}
26+
27+
const portableTextComponents: PortableTextComponents = {
28+
block: {
29+
h2: ({ children, value }) => (
30+
<h2
31+
id={slugify(getHeadingText(value as IBlogHeading))}
32+
className="mt-12 scroll-mt-28 font-incognito text-3xl font-semibold"
33+
>
34+
{children}
35+
</h2>
36+
),
37+
h3: ({ children, value }) => (
38+
<h3
39+
id={slugify(getHeadingText(value as IBlogHeading))}
40+
className="mt-10 scroll-mt-28 font-incognito text-2xl font-semibold"
41+
>
42+
{children}
43+
</h3>
44+
),
45+
h4: ({ children, value }) => (
46+
<h4
47+
id={slugify(getHeadingText(value as IBlogHeading))}
48+
className="mt-8 scroll-mt-28 font-incognito text-xl font-semibold"
49+
>
50+
{children}
51+
</h4>
52+
),
53+
},
54+
types: {
55+
image: ({ value }) => {
56+
if (!value) {
57+
return null;
58+
}
59+
60+
return (
61+
<div className="my-8 overflow-hidden rounded-2xl border border-border/70">
62+
<Image
63+
src={urlForBlogImage(value)}
64+
alt={value.alt || "Blog image"}
65+
width={1600}
66+
height={900}
67+
className="h-auto w-full object-cover"
68+
/>
69+
</div>
70+
);
71+
},
72+
code: ({ value }) => (
73+
<pre className="my-6 overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 p-4 text-sm leading-6">
74+
<code>{value.code}</code>
75+
</pre>
76+
),
77+
table: ({ value }) => {
78+
const rows = value?.rows || [];
79+
80+
if (!rows.length) {
81+
return null;
82+
}
83+
84+
return (
85+
<div className="my-8 overflow-x-auto rounded-2xl border border-border/70">
86+
<table className="min-w-full border-collapse text-sm">
87+
<tbody>
88+
{rows.map((row: { _key?: string; cells?: string[] }, rowIndex: number) => (
89+
<tr key={row._key || rowIndex} className="border-b border-border/70">
90+
{row.cells?.map((cell, cellIndex) => (
91+
<td key={`${row._key || rowIndex}-${cellIndex}`} className="px-4 py-3">
92+
{cell}
93+
</td>
94+
))}
95+
</tr>
96+
))}
97+
</tbody>
98+
</table>
99+
</div>
100+
);
101+
},
102+
},
103+
marks: {
104+
link: ({ children, value }) => {
105+
const href = value?.href || "#";
106+
const isExternal = href.startsWith("http");
107+
108+
return (
109+
<a
110+
href={href}
111+
target={isExternal ? "_blank" : undefined}
112+
rel={isExternal ? "noreferrer noopener" : undefined}
113+
className="text-primary underline decoration-primary/40 underline-offset-4 transition-colors hover:decoration-primary"
114+
>
115+
{children}
116+
</a>
117+
);
118+
},
119+
},
120+
};
121+
122+
export async function generateStaticParams() {
123+
const slugs: Array<{ slug: string }> = await getBlogSlugs();
124+
125+
return slugs.map(({ slug }) => ({ slug }));
126+
}
127+
128+
export default async function BlogArticlePage({ params }: { params: Promise<{ slug: string }> }) {
129+
const { slug } = await params;
130+
const article: IBlogArticle | null = await getBlogBySlug(slug);
131+
132+
if (!article) {
133+
notFound();
134+
}
135+
136+
const headings = article.headings?.filter((heading) => getHeadingText(heading)) || [];
137+
const coverImageUrl = article.mainImage ? urlForBlogImage(article.mainImage) : null;
138+
139+
return (
140+
<section className="mx-auto flex max-w-6xl flex-col gap-10 px-6 pb-24 pt-28 md:px-16">
141+
<div className="flex flex-col gap-6 border-b border-border/70 pb-10">
142+
<Link
143+
href="/blog"
144+
className="text-sm uppercase tracking-[0.2em] text-muted-foreground transition-colors hover:text-foreground"
145+
>
146+
Back to blog
147+
</Link>
148+
149+
<div className="flex max-w-4xl flex-col gap-5">
150+
<h1 className="font-incognito text-4xl font-semibold leading-tight md:text-6xl">
151+
{article.title}
152+
</h1>
153+
<p className="text-base leading-7 text-muted-foreground md:text-lg">
154+
{article.smallDesc}
155+
</p>
156+
</div>
157+
158+
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
159+
<span>{formatDate(article.date)}</span>
160+
<span className="h-1 w-1 rounded-full bg-primary/70" />
161+
<span>{getAuthorName(article.author)}</span>
162+
{article.categories?.length ? (
163+
<>
164+
<span className="h-1 w-1 rounded-full bg-primary/70" />
165+
<span>{article.categories.map((category) => category.title).join(", ")}</span>
166+
</>
167+
) : null}
168+
</div>
169+
</div>
170+
171+
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_240px]">
172+
<article className="min-w-0">
173+
{coverImageUrl ? (
174+
<div className="relative mb-10 aspect-[16/9] overflow-hidden rounded-[2rem] border border-border/70">
175+
<Image
176+
src={coverImageUrl}
177+
alt={article.title}
178+
fill
179+
priority
180+
className="object-cover"
181+
sizes="(max-width: 1024px) 100vw, 70vw"
182+
/>
183+
</div>
184+
) : null}
185+
186+
<div className="prose prose-neutral max-w-none dark:prose-invert prose-headings:font-incognito prose-a:text-primary prose-pre:bg-transparent">
187+
<PortableText
188+
value={article.body}
189+
components={portableTextComponents}
190+
onMissingComponent={false}
191+
/>
192+
</div>
193+
</article>
194+
195+
<aside className="space-y-4 lg:sticky lg:top-28 lg:self-start">
196+
<div className="rounded-2xl border border-border/70 bg-background/80 p-5 backdrop-blur-sm">
197+
<p className="text-sm uppercase tracking-[0.2em] text-muted-foreground">Contents</p>
198+
<div className="mt-4 flex flex-col gap-3">
199+
{headings.length ? (
200+
headings.map((heading) => {
201+
const text = getHeadingText(heading);
202+
203+
return (
204+
<a
205+
key={heading._key}
206+
href={`#${slugify(text)}`}
207+
className="text-sm leading-6 text-muted-foreground transition-colors hover:text-foreground"
208+
>
209+
{text}
210+
</a>
211+
);
212+
})
213+
) : (
214+
<p className="text-sm text-muted-foreground">
215+
This article has no section headings.
216+
</p>
217+
)}
218+
</div>
219+
</div>
220+
221+
<div className="rounded-2xl border border-border/70 bg-muted/30 p-5">
222+
<p className="text-sm leading-6 text-muted-foreground">
223+
Source: Sanity project{" "}
224+
<span className="font-mono text-foreground">{blogProjectId}</span> in dataset{" "}
225+
<span className="font-mono text-foreground">{blogDataset}</span>.
226+
</p>
227+
<Button asChild variant="outline" className="mt-4 w-full">
228+
<Link href="/blog">Browse more posts</Link>
229+
</Button>
230+
</div>
231+
</aside>
232+
</div>
233+
</section>
234+
);
235+
}

app/(app)/blog/page.tsx

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,102 @@
1+
import { Card, CardContent } from "@/components/ui/card";
12
import SectionWrapper from "@/components/ui/section-wrapper";
3+
import { getBlogs } from "@/lib/blog";
4+
import { formatDate } from "@/lib/utils";
5+
import { urlForBlogImage } from "@/sanity/blog-image";
6+
import { IBlogCard } from "@/types/blog";
7+
import Image from "next/image";
8+
import Link from "next/link";
29

3-
export default function BlogPage() {
4-
return <SectionWrapper>Blog page</SectionWrapper>;
10+
export const revalidate = 30;
11+
12+
function getAuthorName(author: IBlogCard["author"]) {
13+
return author?.name || "Marcus Nguyen";
14+
}
15+
16+
export default async function BlogPage() {
17+
const posts: IBlogCard[] = await getBlogs();
18+
19+
return (
20+
<SectionWrapper className="pt-24">
21+
<div className="flex max-w-3xl flex-col gap-4">
22+
<p className="text-sm uppercase tracking-[0.28em] text-primary">Writing</p>
23+
<h1 className="font-incognito text-4xl font-semibold md:text-6xl">
24+
Notes on engineering, frontend craft, and the work behind shipped products.
25+
</h1>
26+
<p className="max-w-2xl text-base text-muted-foreground md:text-lg">
27+
Articles are now served directly inside the portfolio while still reading from the
28+
separate Sanity blog project.
29+
</p>
30+
</div>
31+
32+
{posts.length ? (
33+
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
34+
{posts.map((post) => {
35+
const imageUrl = post.mainImage ? urlForBlogImage(post.mainImage) : null;
36+
37+
return (
38+
<Card
39+
key={post.slug}
40+
className="overflow-hidden border-border/70 bg-background/80 backdrop-blur-sm"
41+
>
42+
<Link href={`/blog/${post.slug}`} className="flex h-full flex-col">
43+
<div className="relative aspect-[16/10] overflow-hidden bg-muted/60">
44+
{imageUrl ? (
45+
<Image
46+
src={imageUrl}
47+
alt={post.title}
48+
fill
49+
className="object-cover transition-transform duration-300 hover:scale-105"
50+
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
51+
/>
52+
) : (
53+
<div className="flex h-full items-end bg-gradient-to-br from-primary/20 via-background to-secondary/20 p-6">
54+
<p className="font-incognito text-2xl font-semibold">{post.title}</p>
55+
</div>
56+
)}
57+
</div>
58+
59+
<CardContent className="flex flex-1 flex-col gap-4 p-6">
60+
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
61+
<span>{formatDate(post.date)}</span>
62+
<span className="h-1 w-1 rounded-full bg-primary/70" />
63+
<span>{getAuthorName(post.author)}</span>
64+
</div>
65+
66+
<div className="space-y-3">
67+
<h2 className="font-incognito text-2xl font-semibold leading-tight">
68+
{post.title}
69+
</h2>
70+
<p className="line-clamp-3 text-sm leading-6 text-muted-foreground">
71+
{post.smallDesc}
72+
</p>
73+
</div>
74+
75+
{post.categories?.length ? (
76+
<div className="mt-auto flex flex-wrap gap-2 pt-2">
77+
{post.categories.map((category) => (
78+
<span
79+
key={`${post.slug}-${category.title}`}
80+
className="rounded-full border border-border/70 px-3 py-1 text-xs uppercase tracking-[0.18em] text-muted-foreground"
81+
>
82+
{category.title}
83+
</span>
84+
))}
85+
</div>
86+
) : null}
87+
</CardContent>
88+
</Link>
89+
</Card>
90+
);
91+
})}
92+
</div>
93+
) : (
94+
<Card className="border-dashed border-border/70 bg-background/80">
95+
<CardContent className="p-6 text-muted-foreground">
96+
No blog posts are available from the Sanity blog project yet.
97+
</CardContent>
98+
</Card>
99+
)}
100+
</SectionWrapper>
101+
);
5102
}

app/components/Navbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default function Navbar() {
2020
},
2121
{
2222
title: "Blog",
23-
href: "https://marcusng-blog.vercel.app/",
23+
href: "/blog",
2424
},
2525
{
2626
title: "Photos",

0 commit comments

Comments
 (0)