Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/r2-gc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: r2-gc
on:
schedule:
- cron: '0 8 * * *' # Everyday 08:00 AM UTC
workflow_dispatch: {}

jobs:
run:
runs-on: ubuntu-latest
steps:
- name: Trigger R2 GC
run: |
curl -sS -X POST \
-H "x-cron-token: $R2_CRON_SECRET" \
-H "content-type: application/json" \
-d '{"prefix":"images/","maxAgeDays":60,"minIntervalHours":24,"maxDeletePerRun":250}' \
"https://dtr.northwestern.edu/api/r2-gc"
env:
R2_CRON_SECRET: ${{ secrets.R2_CRON_SECRET }}
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ Website for the Design, Technology, and Research (DTR) program at Northwestern U
```env
AIRTABLE_API_KEY=<api-key-for-airtable>
AIRTABLE_BASE_ID=<base-id-for-airtable>
REVALIDATE_TIME="21600" # Cache revalidation time in seconds (default: 21600 = 6 hours)
AIRTABLE_LOG=1 # Enable Airtable logging in production (1 to enable, 0 to disable)

R2_ENDPOINT="https://<account-id>.r2.cloudflarestorage.com"
R2_ACCESS_KEY_ID="<access-key-id-for-r2>"
R2_SECRET_ACCESS_KEY="<secret-access-key-for-r2>"
R2_BUCKET="<bucket-name-for-r2>"
R2_CLEANUP_MAX_AGE_DAYS="45" # Max age in days for R2 cleanup (default: 45)
R2_CRON_SECRET="<cron-secret-for-r2>" # Secret for R2 cleanup cron job
```

3. Run `yarn install` to install packages.
Expand Down Expand Up @@ -54,5 +54,5 @@ We use [DigitalOcean's App Platform](https://www.digitalocean.com/products/app-p

The website implements a two-tier caching system to minimize Airtable API usage:

1. **Data Caching**: Airtable table data is cached using Next.js `unstable_cache` with automatic revalidation based on `REVALIDATE_TIME`
1. **Data Caching**: Airtable table data is cached using Next.js `use cache` with automatic revalidation based on `cacheLife` (new feature from Next.js 16, default 12 hours).
2. **Image Caching**: Images are downloaded once from Airtable and cached in Cloudflare R2 Bucket with hash-based invalidation and long-term caching headers.
10 changes: 10 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,14 @@ export default antfu({
rules: {
'react/no-array-index-key': 'off',
},
}, {
files: ['src/app/**/page.tsx'],
rules: {
'react-dom/no-dangerously-set-innerhtml': 'off',
},
}, {
files: ['src/lib/airtable/airtable.ts'],
rules: {
'perfectionist/sort-imports': 'off',
},
})
13 changes: 13 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
halfDays: {
stale: 60 * 60 * 6, // 6 hours
revalidate: 60 * 60 * 12, // 12 hours
expire: 60 * 60 * 24 * 30, // 30 days
},
days: {
stale: 60 * 60 * 12, // 12 hours
revalidate: 60 * 60 * 24, // 1 day
expire: 60 * 60 * 24 * 60, // 30 days
},
},
reactStrictMode: true,
images: {
localPatterns: [
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
},
"scripts": {
"dev": "next dev",
"build": "yarn lint:fix && NODE_NO_WARNINGS=1 next build",
"start": "NODE_NO_WARNINGS=1 next start -H 0.0.0.0 -p ${PORT:-8080}",
"build": "yarn lint:fix && next build",
"start": "next start -H 0.0.0.0 -p ${PORT:-8080}",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"postinstall": "husky"
Expand All @@ -20,6 +20,7 @@
"@next/third-parties": "16.0.0",
"@zl-asica/react": "^0.7.4",
"airtable": "^0.12.2",
"clsx": "^2.1.1",
"d3-scale": "^4.0.2",
"framer-motion": "^12.23.24",
"lucide-react": "^0.507.0",
Expand All @@ -31,6 +32,8 @@
"react-markdown": "^10.1.0",
"react-player": "^2.16.0",
"react-simple-maps": "^3.0.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"rss": "^1.2.2",
"sharp": "^0.34.4"
},
Expand Down
2 changes: 0 additions & 2 deletions src/app/api/images/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { NextResponse } from 'next/server'
import { r2Get, r2Head, r2Put, r2PutTags } from '@/lib/r2'
import { transcodeBufferToWebp } from '@/utils/image-convert'

export const runtime = 'nodejs'

function toWebpKey(attId: string, variant: string, filename: string) {
// Normalize filename to `.webp` extension; keep original basename for readability.
const safeName = filename.replace(/\.[^.]+$/, '') || 'image'
Expand Down
35 changes: 35 additions & 0 deletions src/app/api/r2-gc/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import process from 'node:process'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { maybeRunR2CleanupFromISR } from '@/lib/r2/r2-gc'

export async function POST(req: Request) {
const h = await headers()
const tokenFromHeader = h.get('x-cron-token') ?? ''
const tokenFromQuery = new URL(req.url).searchParams.get('token')
const token = tokenFromHeader ?? tokenFromQuery
if (process.env.R2_CRON_SECRET !== undefined && token !== process.env.R2_CRON_SECRET) {
return NextResponse.json({ ok: false, error: 'unauthorized' }, { status: 401 })
}
const body = await req.json().catch(() => ({})) as unknown
const {
prefix = 'images/',
maxAgeDays = 60,
minIntervalHours = 24,
maxDeletePerRun = 250,
} = body as Partial<{
prefix: string
maxAgeDays: number
minIntervalHours: number
maxDeletePerRun: number
}>

const result = await maybeRunR2CleanupFromISR({
prefix,
maxAgeDays,
minIntervalHours,
maxDeletePerRun,
})

return NextResponse.json({ ok: true, ...result })
}
15 changes: 12 additions & 3 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@import 'tailwindcss';
@import "tailwindcss";

@plugin '@tailwindcss/typography';

Expand Down Expand Up @@ -102,15 +102,17 @@ body {
/* From: https://tailwindcomponents.com/component/link-underline-animation */
.link-underline {
border-bottom-width: 0;
background-image: linear-gradient(transparent, transparent), linear-gradient(#fff, #fff);
background-image:
linear-gradient(transparent, transparent), linear-gradient(#fff, #fff);
background-size: 0 1px;
background-position: 0 100%;
background-repeat: no-repeat;
transition: background-size 0.5s ease-in-out;
}

.link-underline-black {
background-image: linear-gradient(transparent, transparent), linear-gradient(black, black);
background-image:
linear-gradient(transparent, transparent), linear-gradient(black, black);
}

.link-underline:hover {
Expand All @@ -132,3 +134,10 @@ body {
background-size: 400% 100%;
animation: shimmer 1.5s infinite linear;
}

/* Hide native WebKit search cancel button (blue X) globally */
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
display: none;
}
7 changes: 2 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { GoogleAnalytics } from '@next/third-parties/google'
import { Lato } from 'next/font/google'
import Container from '@/components/shared/Container'
import Header from '@/components/shared/Header'
import PopupAnnouncement from '@/components/shared/PopupAnnouncement'
import RouterTransition from '@/components/shared/RouterTransition'
Expand Down Expand Up @@ -48,10 +47,8 @@ export default function RootLayout({
<RouterTransition />
<PopupAnnouncement />
<Header />
<main className="pt-16">
<Container className="mt-8">
{children}
</Container>
<main id="main" className="mt-8 mx-auto w-full max-w-7xl px-4 pt-16">
{children}
</main>
</body>
<GoogleAnalytics gaId="G-0LME5PW7PW" />
Expand Down
9 changes: 8 additions & 1 deletion src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ function Custom404() {
</p>
<Link
href="/"
className="rounded bg-dark-yellow px-4 py-2 text-black no-underline transition-all duration-500 hover:scale-110 hover:bg-dark-yellow hover:text-black"
className="rounded bg-dark-yellow px-4 py-2 text-black no-underline transition-all duration-500 hover:scale-105 hover:bg-dark-yellow hover:text-black"
>
Back to Home
</Link>
<button
type="button"
onClick={() => globalThis.history.back()}
className="rounded mt-4 bg-neutral-200 px-4 py-2 text-neutral-800 no-underline transition-all duration-500 hover:scale-105 hover:bg-neutral-300 hover:text-neutral-900 cursor-pointer"
>
Back to Previous Page
</button>
</main>
)
}
Expand Down
17 changes: 8 additions & 9 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import Carousel from '@/components/home/Carousel'
import HomeIntro from '@/components/home/HomeIntro'
import MediaBanner from '@/components/home/MediaBanner'
import Slides from '@/components/home/Slides'
import Container from '@/components/shared/Container'

export default function HomePage() {
return (
<>
<Container className="flex flex-col gap-6 md:flex-row md:items-center">
<Slides className="w-full md:w-2/3" />
<div className="mx-2 md:mx-4">
<div className="flex flex-col gap-6 md:flex-row md:items-center">
<Carousel className="w-full md:w-2/3" />
<HomeIntro className="w-full md:w-1/3" />
</Container>
</div>

<Container className="mt-4">
<div className="mt-4">
<MediaBanner />
</Container>
</>
</div>
</div>
)
}
12 changes: 0 additions & 12 deletions src/app/people/page.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
import type { Metadata } from 'next'
import PeopleDirectory from '@/components/people/PeopleDirectory'
import { fetchPeople } from '@/lib/airtable/people'
import { maybeRunR2CleanupFromISR } from '@/lib/r2/r2-gc'
import { sortPeople } from '@/utils'

// Revalidate every 12 hours, maximum 73 times per month
export const revalidate = 43200

export const metadata: Metadata = {
title: 'People | DTR',
description: 'Meet the diverse and talented individuals who make up the DTR community, including faculty, students, and alumni.',
alternates: { canonical: 'https://dtr.northwestern.edu/people' },
}

export default async function PeoplePage() {
// Throttled GC: at most once every 24h; delete objects not accessed in 60 days
await maybeRunR2CleanupFromISR({
prefix: 'images/',
maxAgeDays: 60,
minIntervalHours: 24,
maxDeletePerRun: 250,
})

const people = sortPeople((await fetchPeople()) ?? [])

return (
Expand Down
Loading