Skip to content

Commit 90b26d7

Browse files
codercatdevclaude
andcommitted
feat: Astro layout, components, and listing routes
Ports the shared layout and every listing route from the Next app, with production parity as the target rather than a redesign. Design tokens come from apps/web/app/globals.css — the live production stylesheet — not the pre-Next Astro app on `dev`, which carried a different, never-shipped dark-first token set that would have been a visual regression at cutover. Dashboard-only tokens are dropped. Routes: /, /blog + /blog/page/[num], /podcasts + /podcasts/page/[num], /authors|guests|sponsors/page/[num], and 404. Pagination is a runtime bounds check on Astro.params — there is no getStaticPaths — rejecting anything that is not a bare positive integer so one page cannot be served under unlimited URLs, and 302-ing out-of-range requests to the last page. Redirects mirror production exactly: /authors, /guests and /sponsors redirect to /page/1, and the bare /{base}/page stubs go to /page/1 (not to the section index, which is what the scaffold assumed). Components ship no framework JS. next-themes becomes an inline script, the Radix Sheet becomes a details-free drawer, react-icons paths are inlined, and usePathname-based active-nav detection is resolved at render time. A blog listing page now carries 1.4 KB of inline script and no external bundle. Two fixes over the Next behaviour: - Canonical and og:url were derived from build-time `Astro.site`, but SITE_URL is a per-environment wrangler var and CI builds once for both Workers — the dev Worker would have claimed production URLs. Resolved per request in middleware as `Astro.locals.siteUrl`. - JSON-LD advertised an Organization logo at /icon.svg, which 404s in production because Next serves that file at a hashed metadata URL. The asset is now in public/ and the URL resolves. The home page's two-column grids drop the `slice(0, length / 2)` index arithmetic, which silently dropped the last item on an odd count. Verified against a real Worker under `wrangler dev`: all routes 200, /blog/page/abc 404s, /blog/page/99999 302s to the last page, no stega leakage into attributes, and .assetsignore keeps _worker.js unserved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZ4S1uTJwFdbv3A6PHWfw2
1 parent ed452ba commit 90b26d7

45 files changed

Lines changed: 2636 additions & 34 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/site/astro.config.mjs‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "node:path";
33
import cloudflare from "@astrojs/cloudflare";
44
import react from "@astrojs/react";
55
import tailwindcss from "@tailwindcss/vite";
6-
import { defineConfig } from "astro/config";
6+
import { defineConfig, fontProviders } from "astro/config";
77

88
/**
99
* Inline font files as Uint8Array at build time.
@@ -43,12 +43,42 @@ export default defineConfig({
4343
platformProxy: { enabled: true },
4444
}),
4545
integrations: [react()],
46+
// Replaces next/font/google. The CSS variable names match the ones
47+
// apps/web/app/globals.css already binds to body / headings, so the token
48+
// contract carries over unchanged.
49+
fonts: [
50+
{
51+
provider: fontProviders.google(),
52+
name: "Inter",
53+
cssVariable: "--font-inter",
54+
weights: ["400 700"],
55+
styles: ["normal"],
56+
subsets: ["latin"],
57+
fallbacks: ["system-ui", "sans-serif"],
58+
},
59+
{
60+
provider: fontProviders.google(),
61+
name: "Nunito",
62+
cssVariable: "--font-nunito",
63+
weights: ["400 900"],
64+
styles: ["normal"],
65+
subsets: ["latin"],
66+
fallbacks: ["system-ui", "sans-serif"],
67+
},
68+
],
69+
// Mirrors the Next app exactly. The bare `/{base}/page` stubs and the
70+
// `/authors` `/guests` `/sponsors` index routes were `redirect()` calls in
71+
// Server Components, i.e. 307; 302 is the closest config-level equivalent
72+
// and keeps them uncacheable, which a 301 would not be.
4673
redirects: {
47-
"/blog/page": "/blog",
48-
"/podcasts/page": "/podcasts",
49-
"/authors/page": "/authors",
50-
"/guests/page": "/guests",
51-
"/sponsors/page": "/sponsors",
74+
"/blog/page": { status: 302, destination: "/blog/page/1" },
75+
"/podcasts/page": { status: 302, destination: "/podcasts/page/1" },
76+
"/authors": { status: 302, destination: "/authors/page/1" },
77+
"/authors/page": { status: 302, destination: "/authors/page/1" },
78+
"/guests": { status: 302, destination: "/guests/page/1" },
79+
"/guests/page": { status: 302, destination: "/guests/page/1" },
80+
"/sponsors": { status: 302, destination: "/sponsors/page/1" },
81+
"/sponsors/page": { status: 302, destination: "/sponsors/page/1" },
5282
},
5383
vite: {
5484
plugins: [tailwindcss(), rawFonts([".ttf", ".otf"])],

‎apps/site/public/.assetsignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_worker.js

‎apps/site/public/ads.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
google.com, pub-8603586563858288, DIRECT, f08c47fec0942fa0
3.6 KB
Loading

‎apps/site/public/favicon.ico‎

4.19 KB
Binary file not shown.

‎apps/site/public/icon.svg‎

Lines changed: 35 additions & 0 deletions
Loading
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
import AjHeadphones from "@/components/icons/AjHeadphones.astro";
3+
import AjPrimaryAlt from "@/components/icons/AjPrimaryAlt.astro";
4+
import { HERO_ICONS } from "@/lib/hero-icons";
5+
6+
/**
7+
* Ported from the Next `animated-hero.tsx`. Its only state was `isHovered`,
8+
* driving a transform on the two paw marks — expressed here with
9+
* `group-hover:`, so the hero ships no JavaScript at all.
10+
*/
11+
---
12+
13+
<div class="relative w-full h-[500px] overflow-hidden">
14+
<div class="absolute inset-0 grid grid-cols-4 grid-rows-3 gap-4" aria-hidden="true">
15+
{
16+
HERO_ICONS.map((icon, index) => (
17+
<div
18+
class="flex items-center justify-center text-muted-foreground/20 animate-float"
19+
style={`animation-delay: ${index * 0.5}s`}
20+
>
21+
<svg class="size-16" viewBox={icon.viewBox} fill="currentColor">
22+
{icon.paths.map((d) => (
23+
<path d={d} />
24+
))}
25+
</svg>
26+
</div>
27+
))
28+
}
29+
</div>
30+
<div class="relative z-10 flex flex-col items-center justify-center h-full space-y-4">
31+
<div class="group relative bg-background/80 p-8 rounded-lg">
32+
<h1 class="text-4xl font-bold tracking-tighter text-center sm:text-5xl md:text-6xl">
33+
CodingCat.dev Podcast
34+
</h1>
35+
<p class="max-w-[600px] text-center text-muted-foreground md:text-xl/relaxed">
36+
Purrfect Podcast for Web Developers
37+
</p>
38+
<div class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-lg pointer-events-none">
39+
<AjHeadphones
40+
cls="absolute w-24 h-24 -top-12 -right-12 transition-transform duration-500 animate-paw-one group-hover:animate-none group-hover:translate-x-32 group-hover:-translate-y-32 group-hover:rotate-45"
41+
/>
42+
<AjPrimaryAlt
43+
cls="absolute w-24 h-24 -bottom-12 -left-12 transition-transform duration-500 animate-paw-two group-hover:animate-none group-hover:-translate-x-32 group-hover:translate-y-32 group-hover:-rotate-45"
44+
/>
45+
</div>
46+
</div>
47+
</div>
48+
</div>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
import { stegaClean } from "@sanity/client/stega";
3+
import type { SanityImageSource } from "@sanity/image-url";
4+
5+
interface Props {
6+
name?: string | null;
7+
href?: string | null;
8+
coverImage?: SanityImageSource | null;
9+
imgSize?: string;
10+
width?: number;
11+
height?: number;
12+
}
13+
14+
const {
15+
name,
16+
href,
17+
coverImage,
18+
imgSize = "w-12 h-12 mr-4",
19+
width = 48,
20+
height = 48,
21+
} = Astro.props;
22+
23+
const src = Astro.locals.sanity
24+
.urlForImage(coverImage ?? undefined)
25+
?.width(width)
26+
.height(height)
27+
.url();
28+
29+
// stegaClean: href is an attribute; zero-width characters break the URL.
30+
const cleanHref = stegaClean(href);
31+
---
32+
33+
{
34+
src &&
35+
(cleanHref ? (
36+
<a class="flex items-center text-xl" href={cleanHref}>
37+
<div class={imgSize}>
38+
<img
39+
class="w-full h-auto aspect-square rounded-md object-cover"
40+
src={src}
41+
width={width}
42+
height={height}
43+
alt={stegaClean(name) ?? ""}
44+
loading="lazy"
45+
decoding="async"
46+
/>
47+
</div>
48+
{name && (
49+
<div class="text-xl font-bold text-pretty hover:underline">{name}</div>
50+
)}
51+
</a>
52+
) : (
53+
<div class={imgSize}>
54+
<img
55+
class="w-full h-auto aspect-square rounded-md object-cover"
56+
src={src}
57+
width={width}
58+
height={height}
59+
alt={stegaClean(name) ?? ""}
60+
loading="lazy"
61+
decoding="async"
62+
/>
63+
</div>
64+
))
65+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
interface Props {
3+
class?: string;
4+
}
5+
6+
const { class: className = "" } = Astro.props;
7+
---
8+
9+
<div class:list={["container px-5 mx-auto", className]}>
10+
<slot />
11+
</div>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
import Avatar from "@/components/Avatar.astro";
3+
import CoverImage from "@/components/CoverImage.astro";
4+
import FormattedDate from "@/components/FormattedDate.astro";
5+
import { type ContentListItem, isPerson } from "@/lib/content";
6+
import { resolveHref } from "@/lib/sanity/resolve-href";
7+
8+
interface Props {
9+
item: ContentListItem;
10+
priority?: boolean;
11+
}
12+
13+
const { item, priority = false } = Astro.props;
14+
15+
const href = resolveHref(item._type, item.slug ?? undefined);
16+
const person = isPerson(item._type);
17+
const people = [
18+
...(item.author ?? []).map((p) => ({ ...p, type: "author" })),
19+
...(item.guest ?? []).map((p) => ({ ...p, type: "guest" })),
20+
];
21+
---
22+
23+
<article>
24+
{
25+
href && (
26+
<a href={href} class="block mb-5 group">
27+
{person ? (
28+
<CoverImage
29+
image={item.coverImage}
30+
width={256}
31+
height={256}
32+
class="w-full h-auto aspect-square rounded-md object-cover"
33+
priority={priority}
34+
/>
35+
) : (
36+
<CoverImage image={item.coverImage} priority={priority} />
37+
)}
38+
</a>
39+
)
40+
}
41+
<h3 class="mb-3 text-3xl leading-snug text-balance">
42+
{
43+
href ? (
44+
<a href={href} class="hover:underline">
45+
{item.title}
46+
</a>
47+
) : (
48+
item.title
49+
)
50+
}
51+
</h3>
52+
{
53+
!person && item.date && (
54+
<div class="mb-4 text-lg text-muted-foreground">
55+
<FormattedDate dateString={item.date} />
56+
</div>
57+
)
58+
}
59+
{item.excerpt && <p class="mb-4 text-lg leading-relaxed text-pretty">{item.excerpt}</p>}
60+
{
61+
people.length > 0 && (
62+
<div class="flex flex-wrap gap-2">
63+
{people.map((p) => (
64+
<Avatar
65+
name={p.title}
66+
href={resolveHref(p.type, p.slug ?? undefined)}
67+
coverImage={p.coverImage}
68+
/>
69+
))}
70+
</div>
71+
)
72+
}
73+
</article>

0 commit comments

Comments
 (0)