Skip to content

Commit 8b42a0a

Browse files
authored
feat: Batch 1 page migration — homepage, blog, podcast pages (#671)
Migrates 5 highest-traffic pages from Next.js to Astro 6:\n\n- Homepage with latest/top podcasts and posts from Sanity\n- Blog listing with server-side pagination (?page=N)\n- Post detail with Portable Text, YouTube embeds, tags\n- Podcast listing with server-side pagination\n- Podcast detail with YouTube, Spotify, guests, picks\n\nShared components: ContentCard (WebP images), Pagination (ellipsis nav), BaseLayout (header/footer nav)\n\nGROQ queries ported 1:1 from old sanity/lib/queries.ts. Zero client-side JavaScript on content pages.\n\nIncludes: page number validation, SEO description meta, datetime attributes, $end variable rename."
1 parent a67125d commit 8b42a0a

File tree

11 files changed

+15701
-4064
lines changed

11 files changed

+15701
-4064
lines changed

apps/web/package-lock.json

Lines changed: 15113 additions & 3948 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
import { urlForImage } from "@/utils/sanity";
3+
4+
interface Props {
5+
title: string;
6+
slug: string;
7+
coverImage?: any;
8+
excerpt?: string;
9+
date?: string;
10+
}
11+
12+
const { title, slug, coverImage, excerpt, date } = Astro.props;
13+
14+
const imageUrl = coverImage
15+
? urlForImage(coverImage).width(640).height(360).format("webp").url()
16+
: null;
17+
18+
const formattedDate = date
19+
? new Date(date).toLocaleDateString("en-US", {
20+
year: "numeric",
21+
month: "short",
22+
day: "numeric",
23+
})
24+
: null;
25+
---
26+
27+
<a href={slug} class="group block rounded-lg border hover:border-blue-500 transition-colors overflow-hidden">
28+
{imageUrl && (
29+
<img
30+
src={imageUrl}
31+
alt={title}
32+
width={640}
33+
height={360}
34+
class="w-full aspect-video object-cover"
35+
loading="lazy"
36+
/>
37+
)}
38+
<div class="p-4">
39+
<h3 class="font-semibold text-lg group-hover:text-blue-600 transition-colors line-clamp-2">
40+
{title}
41+
</h3>
42+
{excerpt && (
43+
<p class="text-gray-600 text-sm mt-2 line-clamp-2">{excerpt}</p>
44+
)}
45+
{formattedDate && (
46+
<time datetime={date} class="text-gray-400 text-xs mt-2 block">{formattedDate}</time>
47+
)}
48+
</div>
49+
</a>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
interface Props {
3+
currentPage: number;
4+
totalPages: number;
5+
basePath: string;
6+
}
7+
8+
const { currentPage, totalPages, basePath } = Astro.props;
9+
10+
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
11+
const showPages = pages.filter(
12+
(p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2
13+
);
14+
---
15+
16+
<nav class="flex justify-center items-center gap-2 mt-12" aria-label="Pagination">
17+
{currentPage > 1 && (
18+
<a
19+
href={currentPage === 2 ? basePath : `${basePath}?page=${currentPage - 1}`}
20+
class="px-4 py-2 border rounded-lg hover:bg-gray-50 transition-colors"
21+
>
22+
&larr; Previous
23+
</a>
24+
)}
25+
26+
{showPages.map((p, i) => {
27+
const prev = showPages[i - 1];
28+
const showEllipsis = prev && p - prev > 1;
29+
return (
30+
<>
31+
{showEllipsis && <span class="px-2">&hellip;</span>}
32+
<a
33+
href={p === 1 ? basePath : `${basePath}?page=${p}`}
34+
class:list={[
35+
"px-4 py-2 rounded-lg transition-colors",
36+
p === currentPage
37+
? "bg-blue-600 text-white"
38+
: "border hover:bg-gray-50",
39+
]}
40+
>
41+
{p}
42+
</a>
43+
</>
44+
);
45+
})}
46+
47+
{currentPage < totalPages && (
48+
<a
49+
href={`${basePath}?page=${currentPage + 1}`}
50+
class="px-4 py-2 border rounded-lg hover:bg-gray-50 transition-colors"
51+
>
52+
Next &rarr;
53+
</a>
54+
)}
55+
</nav>

apps/web/src/layouts/BaseLayout.astro

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface Props {
66
description?: string;
77
}
88
9-
const { title, description = "CodingCat.dev - Purrfect Web Tutorials" } = Astro.props;
9+
const { title, description = "CodingCat.dev Purrfect Web Tutorials" } = Astro.props;
1010
---
1111

1212
<!doctype html>
@@ -15,10 +15,29 @@ const { title, description = "CodingCat.dev - Purrfect Web Tutorials" } = Astro.
1515
<meta charset="UTF-8" />
1616
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1717
<meta name="description" content={description} />
18-
<title>{title} | CodingCat.dev</title>
1918
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
19+
<title>{title}</title>
2020
</head>
21-
<body class="min-h-screen bg-background text-foreground">
22-
<slot />
21+
<body class="min-h-screen flex flex-col">
22+
<header class="border-b">
23+
<nav class="container mx-auto px-4 py-4 flex items-center justify-between">
24+
<a href="/" class="text-xl font-bold">🐱 CodingCat.dev</a>
25+
<div class="flex items-center gap-6">
26+
<a href="/blog" class="hover:text-blue-600 transition-colors">Blog</a>
27+
<a href="/podcasts" class="hover:text-blue-600 transition-colors">Podcasts</a>
28+
<a href="/dashboard" class="hover:text-blue-600 transition-colors">Dashboard</a>
29+
</div>
30+
</nav>
31+
</header>
32+
33+
<div class="flex-1">
34+
<slot />
35+
</div>
36+
37+
<footer class="border-t mt-16">
38+
<div class="container mx-auto px-4 py-8 text-center text-gray-500">
39+
<p>&copy; {new Date().getFullYear()} CodingCat.dev. All rights reserved.</p>
40+
</div>
41+
</footer>
2342
</body>
2443
</html>

apps/web/src/lib/queries.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import groq from "groq";
2+
3+
const baseFields = `
4+
_id,
5+
_type,
6+
"title": coalesce(title, "Untitled"),
7+
"slug": slug.current,
8+
excerpt,
9+
coverImage,
10+
"date": coalesce(date, _createdAt)
11+
`;
12+
13+
const contentFields = `
14+
content[]{
15+
...,
16+
markDefs[]{
17+
...,
18+
_type == "internalLink" => {
19+
@.reference->_type == "page" => {
20+
"href": "/" + @.reference->slug.current
21+
},
22+
@.reference->_type != "page" => {
23+
"href": "/" + @.reference->_type + "/" + @.reference->slug.current
24+
}
25+
},
26+
}
27+
},
28+
author[]->{
29+
...,
30+
"title": coalesce(title, "Anonymous"),
31+
"slug": slug.current,
32+
},
33+
sponsor[]->{
34+
...,
35+
"title": coalesce(title, "Anonymous"),
36+
"slug": slug.current,
37+
},
38+
tags,
39+
youtube
40+
`;
41+
42+
const podcastFields = `
43+
podcastType[]->{
44+
...,
45+
"title": coalesce(title, "Missing Podcast Title"),
46+
},
47+
season,
48+
episode,
49+
recordingDate,
50+
guest[]->{
51+
...,
52+
"title": coalesce(title, "Anonymous"),
53+
"slug": slug.current,
54+
},
55+
pick[]{
56+
user->,
57+
name,
58+
site
59+
},
60+
spotify
61+
`;
62+
63+
export const homePageQuery = groq`*[_type == "settings"][0]{
64+
"latestPodcast": *[_type == "podcast"]|order(date desc)[0]{
65+
${baseFields},
66+
youtube,
67+
},
68+
"latestPodcasts": *[_type == "podcast"]|order(date desc)[0...4]{
69+
${baseFields},
70+
},
71+
"topPodcasts": *[_type == "podcast" && statistics.youtube.viewCount > 0]|order(statistics.youtube.viewCount desc)[0...4]{
72+
${baseFields},
73+
},
74+
"latestPosts": *[_type == "post"]|order(date desc)[0...4]{
75+
${baseFields},
76+
},
77+
"topPosts": *[_type == "post" && statistics.youtube.viewCount > 0]|order(statistics.youtube.viewCount desc)[0...4]{
78+
${baseFields},
79+
},
80+
}`;
81+
82+
export const postListQuery = groq`*[_type == "post" && defined(slug.current)] | order(date desc, _updatedAt desc) [$offset...$end] {
83+
${baseFields},
84+
author[]->{
85+
"title": coalesce(title, "Anonymous"),
86+
"slug": slug.current,
87+
}
88+
}`;
89+
90+
export const postQuery = groq`*[_type == "post" && slug.current == $slug][0] {
91+
${baseFields},
92+
${contentFields}
93+
}`;
94+
95+
export const postCountQuery = groq`count(*[_type == "post" && defined(slug.current)])`;
96+
97+
export const podcastListQuery = groq`*[_type == "podcast" && defined(slug.current)] | order(date desc, _updatedAt desc) [$offset...$end] {
98+
${baseFields},
99+
author[]->{
100+
"title": coalesce(title, "Anonymous"),
101+
"slug": slug.current,
102+
},
103+
guest[]->{
104+
"title": coalesce(title, "Anonymous"),
105+
"slug": slug.current,
106+
}
107+
}`;
108+
109+
export const podcastQuery = groq`*[_type == "podcast" && slug.current == $slug][0] {
110+
${baseFields},
111+
${contentFields},
112+
${podcastFields}
113+
}`;
114+
115+
export const podcastCountQuery = groq`count(*[_type == "podcast" && defined(slug.current)])`;
116+
117+
export const settingsQuery = groq`*[_type == "settings"][0]{
118+
...,
119+
ogImage
120+
}`;

apps/web/src/pages/blog.astro

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,50 @@
11
---
22
import BaseLayout from "@/layouts/BaseLayout.astro";
3+
import ContentCard from "@/components/ContentCard.astro";
4+
import Pagination from "@/components/Pagination.astro";
35
import { sanityFetch } from "@/utils/sanity";
6+
import { postListQuery, postCountQuery } from "@/lib/queries";
47
58
export const prerender = false;
69
7-
const posts = await sanityFetch<Array<{
8-
_id: string;
9-
title: string;
10-
slug: { current: string };
11-
excerpt?: string;
12-
_createdAt: string;
13-
}>>(
14-
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc) {
15-
_id, title, slug, excerpt, _createdAt
16-
}`
17-
);
10+
// B1: Validate page number — clamp to positive integer
11+
const rawPage = Number(Astro.url.searchParams.get("page") || "1");
12+
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : Math.floor(rawPage);
13+
const perPage = 12;
14+
const offset = (page - 1) * perPage;
15+
16+
const [posts, totalCount] = await Promise.all([
17+
sanityFetch<any[]>(postListQuery, { offset, end: offset + perPage }),
18+
sanityFetch<number>(postCountQuery),
19+
]);
20+
21+
const totalPages = Math.ceil(totalCount / perPage);
22+
23+
// Redirect if page exceeds total
24+
if (page > totalPages && totalPages > 0) {
25+
return Astro.redirect(`/blog?page=${totalPages}`);
26+
}
1827
---
1928

20-
<BaseLayout title="Blog">
29+
<BaseLayout title="Blog — CodingCat.dev">
2130
<main class="container mx-auto px-4 py-8">
22-
<h1 class="text-4xl font-bold mb-8">Blog</h1>
23-
<div class="space-y-6">
24-
{posts.map((post) => (
25-
<a href={`/post/${post.slug.current}`} class="block p-6 rounded-lg border hover:border-primary transition-colors">
26-
<h2 class="text-2xl font-medium mb-2">{post.title}</h2>
27-
{post.excerpt && <p class="text-muted-foreground mb-2">{post.excerpt}</p>}
28-
<time class="text-sm text-muted-foreground">
29-
{new Date(post._createdAt).toLocaleDateString("en-US", {
30-
year: "numeric",
31-
month: "long",
32-
day: "numeric",
33-
})}
34-
</time>
35-
</a>
31+
<h1 class="text-4xl font-bold mb-2">Blog</h1>
32+
<p class="text-gray-600 mb-8">Web development tutorials, tips, and insights.</p>
33+
34+
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
35+
{posts?.map((post) => (
36+
<ContentCard
37+
title={post.title}
38+
slug={`/post/${post.slug}`}
39+
coverImage={post.coverImage}
40+
excerpt={post.excerpt}
41+
date={post.date}
42+
/>
3643
))}
3744
</div>
45+
46+
{totalPages > 1 && (
47+
<Pagination currentPage={page} totalPages={totalPages} basePath="/blog" />
48+
)}
3849
</main>
3950
</BaseLayout>

0 commit comments

Comments
 (0)