Compare commits

..
2 Commits
Author SHA1 Message Date
patrick eb9006dc19 fix: prevent resume.pdf from being cached immutably
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 1m24s
2026-08-03 00:24:05 -04:00
patrick 07a4812596 feat: add reading list section
Generate a build and push to Cloudflare Pages / Build and Deploy to Cloudflare Pages (push) Successful in 3m47s
2026-08-02 21:15:06 -04:00
29 changed files with 448 additions and 3 deletions
+5
View File
@@ -10,6 +10,11 @@
/fonts2/* /fonts2/*
Cache-Control: public, max-age=31536000, immutable Cache-Control: public, max-age=31536000, immutable
# resume.pdf changes under a stable filename, so it must revalidate
# (unlike the hashed/never-changing assets below).
/static/resume.pdf
Cache-Control: public, max-age=3600, must-revalidate
/static/* /static/*
Cache-Control: public, max-age=31536000, immutable Cache-Control: public, max-age=31536000, immutable
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+106
View File
@@ -0,0 +1,106 @@
<script lang="ts" module>
export interface Book {
title: string
author?: string
isbn: string
status: 'reading' | 'finished'
/** Resolved cover URL (Astro-optimized local image, or a remote fallback). */
coverSrc: string
/** Reading progress, 0100. Only rendered for `status: 'reading'`. */
progress?: number
}
</script>
<script lang="ts">
import IconBook from '~icons/lucide/book'
interface Props {
book: Book
/** 'cover' = vertical cover tile (grids); 'row' = horizontal card (teasers). */
variant?: 'cover' | 'row'
}
let { book, variant = 'cover' }: Props = $props()
let errored = $state(false)
const showProgress = $derived(
book.status === 'reading' && book.progress != null,
)
</script>
{#snippet coverImage()}
{#if errored}
<div
class="flex h-full w-full flex-col items-center justify-center gap-1 p-2 text-center"
>
<IconBook class="size-5 text-primary" />
<span class="line-clamp-3 text-[10px] font-medium text-foreground">{book.title}</span>
</div>
{:else}
<img
alt={`Cover of ${book.title}`}
src={book.coverSrc}
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
onerror={() => (errored = true)}
/>
{/if}
{/snippet}
{#snippet progressBar()}
{#if showProgress}
<div class="mt-1.5 flex items-center gap-2">
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div class="h-full rounded-full bg-primary" style={`width: ${book.progress}%`}></div>
</div>
<span class="text-[10px] font-medium tabular-nums text-muted-foreground">
{book.progress}%
</span>
</div>
{/if}
{/snippet}
{#if variant === 'row'}
<a
class="group flex h-full items-center gap-4 rounded-2xl border border-card-foreground/10 bg-card p-4 transition-all duration-300 hover:translate-y-[-4px] hover:shadow-lg"
href={`https://openlibrary.org/isbn/${book.isbn}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${book.title}${book.author ? ` by ${book.author}` : ''}`}
>
<div
class="aspect-2/3 w-14 shrink-0 overflow-hidden rounded-md border border-card-foreground/10 bg-secondary shadow-sm"
>
{@render coverImage()}
</div>
<div class="min-w-0 flex-1">
<h3 class="line-clamp-2 text-sm font-medium text-foreground">{book.title}</h3>
{#if book.author}
<p class="truncate text-xs text-muted-foreground">{book.author}</p>
{/if}
{@render progressBar()}
</div>
</a>
{:else}
<a
class="group flex flex-col gap-y-2 transition-all duration-300 hover:translate-y-[-4px]"
href={`https://openlibrary.org/isbn/${book.isbn}`}
target="_blank"
rel="noopener noreferrer"
aria-label={`${book.title}${book.author ? ` by ${book.author}` : ''}`}
>
<div
class="aspect-2/3 w-full overflow-hidden rounded-lg border border-card-foreground/10 bg-secondary shadow-sm transition-shadow duration-300 group-hover:shadow-lg"
>
{@render coverImage()}
</div>
<div class="min-w-0">
<h3 class="line-clamp-2 text-sm font-medium text-foreground">{book.title}</h3>
{#if book.author}
<p class="truncate text-xs text-muted-foreground">{book.author}</p>
{/if}
{@render progressBar()}
</div>
</a>
{/if}
+2 -2
View File
@@ -156,7 +156,7 @@
</div> </div>
{/each} {/each}
<a <a
href="/static/resume.pdf" href="/static/resume.pdf?v=2026-08"
download download
class="flex items-center gap-2 rounded-full border border-foreground/20 px-4 py-1.5 text-sm font-medium transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5" class="flex items-center gap-2 rounded-full border border-foreground/20 px-4 py-1.5 text-sm font-medium transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
> >
@@ -211,7 +211,7 @@
transition:slide={{ delay: NAV_LINKS.length * 50, duration: 200 }} transition:slide={{ delay: NAV_LINKS.length * 50, duration: 200 }}
> >
<a <a
href="/static/resume.pdf" href="/static/resume.pdf?v=2026-08"
download download
onclick={closeMobileMenu} onclick={closeMobileMenu}
class="inline-flex items-center gap-2 rounded-full border border-foreground/20 px-5 py-2 text-lg font-bold font-custom transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5" class="inline-flex items-center gap-2 rounded-full border border-foreground/20 px-5 py-2 text-lg font-bold font-custom transition-all duration-200 hover:border-foreground/40 hover:bg-foreground/5"
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import BookCard, { type Book } from './BookCard.svelte'
interface Props {
books: Book[]
}
let { books }: Props = $props()
// Both lists render in the order they appear in reading.ts (curated by
// relevance — most relevant first).
const currentlyReading = $derived(books.filter((b) => b.status === 'reading'))
const finished = $derived(books.filter((b) => b.status === 'finished'))
</script>
<div class="flex flex-col gap-y-12">
{#if currentlyReading.length > 0}
<section aria-labelledby="currently-reading-title">
<h3
id="currently-reading-title"
class="font-custom mb-6 text-xl font-bold text-foreground"
>
Currently Reading
</h3>
<div class="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5">
{#each currentlyReading as book (book.isbn)}
<BookCard {book} />
{/each}
</div>
</section>
{/if}
{#if finished.length > 0}
<section aria-labelledby="finished-title">
<h3 id="finished-title" class="font-custom mb-6 text-xl font-bold text-foreground">
Finished
</h3>
<div class="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{#each finished as book (book.isbn)}
<BookCard {book} />
{/each}
</div>
</section>
{/if}
</div>
+4
View File
@@ -24,6 +24,10 @@ export const NAV_LINKS: SocialLink[] = [
href: '/blog', href: '/blog',
label: 'blog', label: 'blog',
}, },
{
href: '/reading',
label: 'reading',
},
{ {
href: '/#contact', href: '/#contact',
label: 'contact', label: 'contact',
+189
View File
@@ -0,0 +1,189 @@
import type { ImageMetadata } from 'astro'
import devopsHandbook from '@/assets/reading/devops-handbook.jpg'
import osThreeEasyPieces from '@/assets/reading/os-three-easy-pieces.jpg'
import cleanCode from '@/assets/reading/clean-code.jpg'
import refactoring from '@/assets/reading/refactoring.jpg'
import philosophyOfSoftwareDesign from '@/assets/reading/philosophy-of-software-design.jpg'
import architecturePatternsWithPython from '@/assets/reading/architecture-patterns-with-python.jpg'
import fluentPython from '@/assets/reading/fluent-python.jpg'
import effectiveTypeScript from '@/assets/reading/effective-typescript.jpg'
import headFirstDesignPatterns from '@/assets/reading/head-first-design-patterns.jpg'
import ooThoughtProcess from '@/assets/reading/oo-thought-process.jpg'
import dockerUpAndRunning from '@/assets/reading/docker-up-and-running.jpg'
import theCProgrammingLanguage from '@/assets/reading/the-c-programming-language.jpg'
import codePetzold from '@/assets/reading/code-petzold.jpg'
import theoryOfComputation from '@/assets/reading/theory-of-computation.jpg'
import headFirstSoftwareDevelopment from '@/assets/reading/head-first-software-development.jpg'
import docsForDevelopers from '@/assets/reading/docs-for-developers.jpg'
import justForFun from '@/assets/reading/just-for-fun.jpg'
import algorithmsToLiveBy from '@/assets/reading/algorithms-to-live-by.jpg'
import personalMba from '@/assets/reading/personal-mba.jpg'
import fastAPI from '@/assets/reading/fastapi.jpg'
export interface ReadingEntry {
title: string
author?: string
isbn: string
status: 'reading' | 'finished'
/**
* Local cover image (optimized by Astro). When omitted, the cover falls back
* to the Open Library Covers API by ISBN, and then to a title/author card if
* that's unavailable too. To add a missing cover, drop a JPG in
* src/assets/reading/, import it above, and set it here.
*/
cover?: ImageMetadata
/** Reading progress, 0100. Only shown for `status: 'reading'`. */
progress?: number
}
// Order matters: books render in the order listed here, curated by relevance
// (most relevant to my work first).
export const reading: ReadingEntry[] = [
// Currently reading
{
title: 'The DevOps Handbook',
author: 'Gene Kim',
isbn: '9781942788003',
status: 'reading',
cover: devopsHandbook,
progress: 55, // update as you read
},
{
title: 'Operating Systems: Three Easy Pieces',
author: 'Remzi H. Arpaci-Dusseau',
isbn: '9781985086593',
status: 'reading',
cover: osThreeEasyPieces,
progress: 30,
},
{
title: 'Effective TypeScript',
author: 'Dan Vanderkam',
isbn: '9781492053743',
status: 'reading',
cover: effectiveTypeScript,
progress: 45,
},
// Finished — ordered by relevance
{
title: 'Clean Code',
author: 'Robert C. Martin',
isbn: '9780132350884',
status: 'finished',
cover: cleanCode,
},
{
title: 'Refactoring',
author: 'Martin Fowler',
isbn: '9780134757599',
status: 'finished',
cover: refactoring,
},
{
title: 'A Philosophy of Software Design',
author: 'John Ousterhout',
isbn: '9781732102200',
status: 'finished',
cover: philosophyOfSoftwareDesign,
},
{
title: 'Architecture Patterns with Python',
author: 'Harry Percival, Bob Gregory',
isbn: '9781492052203',
status: 'finished',
cover: architecturePatternsWithPython,
},
{
title: 'Fluent Python',
author: 'Luciano Ramalho',
isbn: '9781492056355',
status: 'finished',
cover: fluentPython,
},
{
title: 'Head First Design Patterns',
author: 'Eric Freeman',
isbn: '9781492078005',
status: 'finished',
cover: headFirstDesignPatterns,
},
{
title: 'The Object-Oriented Thought Process',
author: 'Matt Weisfeld',
isbn: '9780321861276',
status: 'finished',
cover: ooThoughtProcess,
},
{
title: 'Docker: Up & Running',
author: 'Sean Kane, Karl Matthias',
isbn: '9781098131821',
status: 'finished',
cover: dockerUpAndRunning,
},
{
title: 'FastAPI',
author: 'Bill Lubanovic',
isbn: '9781098135508',
status: 'finished',
cover: fastAPI,
},
{
title: 'The C Programming Language',
author: 'Brian Kernighan, Dennis Ritchie',
isbn: '9780131103627',
status: 'finished',
cover: theCProgrammingLanguage,
},
{
title: 'Code: The Hidden Language of Computer Hardware and Software',
author: 'Charles Petzold',
isbn: '9780137909100',
status: 'finished',
cover: codePetzold,
},
{
title: 'Introduction to the Theory of Computation',
author: 'Michael Sipser',
isbn: '9781133187790',
status: 'finished',
cover: theoryOfComputation,
},
{
title: 'Head First Software Development',
author: 'Dan Pilone',
isbn: '9780596527358',
status: 'finished',
cover: headFirstSoftwareDevelopment,
},
{
title: 'Docs for Developers',
author: 'Jared Bhatti',
isbn: '9781484272169',
status: 'finished',
cover: docsForDevelopers,
},
{
title: 'Just for Fun',
author: 'Linus Torvalds, David Diamond',
isbn: '9780066620732',
status: 'finished',
cover: justForFun,
},
{
title: 'Algorithms to Live By',
author: 'Brian Christian, Tom Griffiths',
isbn: '9781627790369',
status: 'finished',
cover: algorithmsToLiveBy,
},
{
title: 'The Personal MBA',
author: 'Josh Kaufman',
isbn: '9781591845577',
status: 'finished',
cover: personalMba,
},
]
+20
View File
@@ -1,4 +1,7 @@
import { getCollection, type CollectionEntry } from 'astro:content' import { getCollection, type CollectionEntry } from 'astro:content'
import { getImage } from 'astro:assets'
import { reading } from '@/data/reading'
import type { Book } from '@/components/svelte/BookCard.svelte'
export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> { export async function getAllPosts(): Promise<CollectionEntry<'blog'>[]> {
const posts = await getCollection('blog') const posts = await getCollection('blog')
@@ -92,6 +95,23 @@ export async function getFeaturedProjects(): Promise<CollectionEntry<'projects'>
return projects.slice(0, 4) return projects.slice(0, 4)
} }
export async function getReadingList(): Promise<Book[]> {
return Promise.all(
reading.map(async (entry) => ({
title: entry.title,
author: entry.author,
isbn: entry.isbn,
status: entry.status,
progress: entry.progress,
// Local covers get Astro-optimized to WebP; everything else falls back to
// the Open Library Covers API by ISBN (and then a title/author card).
coverSrc: entry.cover
? (await getImage({ src: entry.cover, width: 400, format: 'webp' })).src
: `https://covers.openlibrary.org/b/isbn/${entry.isbn}-L.jpg?default=false`,
})),
)
}
export async function getProjectsFeaturedTags(maxCount: number): Promise<string[]> { export async function getProjectsFeaturedTags(maxCount: number): Promise<string[]> {
const projects = await getAllProjects() const projects = await getAllProjects()
const tags = new Set<string>() const tags = new Set<string>()
+39 -1
View File
@@ -6,7 +6,7 @@ import Skills from '../components/svelte/Skills.svelte'
import { buttonVariants } from '@/lib/button-variants' import { buttonVariants } from '@/lib/button-variants'
import Logo from '../components/svelte/Logo.svelte' import Logo from '../components/svelte/Logo.svelte'
import Layout from '@/layouts/Layout.astro' import Layout from '@/layouts/Layout.astro'
import { getRecentPosts, getFeaturedProjects } from '@/lib/data-utils' import { getRecentPosts, getFeaturedProjects, getReadingList } from '@/lib/data-utils'
import { SITE } from '@/consts' import { SITE } from '@/consts'
import ProjectCard from '../components/svelte/ProjectCard.svelte' import ProjectCard from '../components/svelte/ProjectCard.svelte'
import IconGit from '~icons/simple-icons/git' import IconGit from '~icons/simple-icons/git'
@@ -19,9 +19,14 @@ import EducationItem from '../components/svelte/EducationItem.svelte'
import { education } from '@/data/education' import { education } from '@/data/education'
import ContactForm from '../components/svelte/ContactForm.svelte' import ContactForm from '../components/svelte/ContactForm.svelte'
import IconMail from '~icons/lucide/mail' import IconMail from '~icons/lucide/mail'
import IconBookOpen from '~icons/lucide/book-open'
import BookCard from '../components/svelte/BookCard.svelte'
const blog = await getRecentPosts(3) const blog = await getRecentPosts(3)
const featuredProjects = await getFeaturedProjects() const featuredProjects = await getFeaturedProjects()
const currentlyReading = (await getReadingList()).filter(
(book) => book.status === 'reading',
)
const currentUrl = Astro.url; const currentUrl = Astro.url;
const turnstileSitekey = import.meta.env.PUBLIC_TURNSTILE_SITEKEY const turnstileSitekey = import.meta.env.PUBLIC_TURNSTILE_SITEKEY
--- ---
@@ -157,6 +162,39 @@ const turnstileSitekey = import.meta.env.PUBLIC_TURNSTILE_SITEKEY
</div> </div>
</section> </section>
{currentlyReading.length > 0 && (
<section class="flex flex-col gap-y-4" aria-labelledby="reading-title" role="region">
<div class="relative">
<h2
id="reading-title"
class="font-custom text-foreground text-2xl font-bold flex items-center gap-2"
>
<IconBookOpen class="size-5" />
Currently Reading
</h2>
<p class="text-muted-foreground text-sm">
What's on my desk right now.
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4">
{currentlyReading.map((book) => (
<BookCard book={book} variant="row" client:load />
))}
</div>
<div class="flex justify-center">
<Link
href="/reading"
class={buttonVariants({ variant: 'ghost' }) + ' group'}
>
View full reading list <span
class="ml-1.5 transition-transform group-hover:translate-x-1"
>&rarr;</span
>
</Link>
</div>
</section>
)}
<section class="flex flex-col gap-y-4" aria-labelledby="education-title" role="region"> <section class="flex flex-col gap-y-4" aria-labelledby="education-title" role="region">
<div class="relative"> <div class="relative">
<h2 <h2
+38
View File
@@ -0,0 +1,38 @@
---
import Breadcrumbs from '@/components/Breadcrumbs.astro'
import PageHead from '@/components/PageHead.astro'
import Layout from '@/layouts/Layout.astro'
import ReadingList from '@/components/svelte/ReadingList.svelte'
import { getReadingList } from '@/lib/data-utils'
import { Icon } from 'astro-icon/components'
const currentUrl = Astro.url
const books = await getReadingList()
---
<Layout canonicalUrl={currentUrl}>
<PageHead slot="head" title="Reading List" />
<Breadcrumbs
items={[{ label: 'Reading', href: '/reading', icon: 'lucide:book-open' }]}
/>
<section class="max-screen mt-12 px-4 md:px-6">
<div class="mb-12">
<div class="flex w-fit items-center gap-2 text-primary">
<Icon name="lucide:book-open" class="h-4 w-4 text-primary" />
<p class="word-spacing font-mono text-sm uppercase leading-none text-primary">
What I'm reading
</p>
</div>
<h2 class="font-custom text-foreground mt-2 text-4xl font-bold">Reading List</h2>
<p class="text-muted-foreground text-md mt-3 max-w-2xl">
Books I'm working through and ones I've finished — mostly software
engineering and computer science, with the occasional detour.
</p>
</div>
<div class="mb-16">
<ReadingList books={books} client:load />
</div>
</section>
</Layout>