Skip to content

Commit 2b0cba1

Browse files
committed
Generate base64 placeholders in build
1 parent ee796a5 commit 2b0cba1

File tree

7 files changed

+73
-15
lines changed

7 files changed

+73
-15
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"numbro": "2.5.0",
6767
"p-limit": "^4.0.0",
6868
"parser-front-matter": "1.6.4",
69+
"plaiceholder": "^3.0.0",
6970
"playwright-core": "^1.54.2",
7071
"postcss": "^8.4.49",
7172
"postcss-import": "^16.1.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/conf/2025/components/speaker-card.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import clsx from "clsx"
22
import Image from "next/image"
3+
import { getPlaiceholder } from "plaiceholder"
34

45
import { Anchor } from "../../_design-system/anchor"
56
import { SchedSpeaker } from "../../2023/types"
@@ -11,6 +12,7 @@ import { SpeakerLinks } from "./speaker-links"
1112
import styles from "./speaker-card.module.css"
1213
import { formatSpeakerPosition } from "./format-speaker-position"
1314
import { formatDescription } from "../schedule/[id]/format-description"
15+
import { getBase64Placeholder } from "../../_design-system/utils/get-base64-placeholder"
1416

1517
export interface SpeakerCardProps extends React.HTMLAttributes<HTMLDivElement> {
1618
isReturning?: boolean
@@ -20,13 +22,30 @@ export interface SpeakerCardProps extends React.HTMLAttributes<HTMLDivElement> {
2022
year: string
2123
}
2224

23-
export function SpeakerCard({
25+
export async function SpeakerCard({
2426
className,
2527
speaker,
2628
year,
2729
showSocials = false,
2830
...props
2931
}: SpeakerCardProps) {
32+
let avatarPlaceholder: string | undefined
33+
34+
if (speaker.avatar) {
35+
try {
36+
if (speaker.avatar.startsWith("//"))
37+
speaker.avatar = `https:${speaker.avatar}`
38+
39+
avatarPlaceholder = await getBase64Placeholder(speaker.avatar)
40+
} catch (e) {
41+
// this might happen in dev server on reloads and it's okay to ignore
42+
console.warn(
43+
"failed to fetch speaker.avatar for placeholder generation:",
44+
e,
45+
)
46+
}
47+
}
48+
3049
return (
3150
<article
3251
className={clsx(
@@ -44,13 +63,14 @@ export function SpeakerCard({
4463
/>
4564
)}
4665

47-
<div className="relative aspect-square h-full overflow-hidden @[420px]:w-[176px] @[420px]:shrink-0">
66+
{/* eslint-disable-next-line tailwindcss/no-custom-classname */}
67+
<div className="Avatar relative aspect-square h-full overflow-hidden @[420px]:w-[176px] @[420px]:shrink-0">
4868
<div className="absolute inset-0 z-[1] bg-sec-light mix-blend-multiply" />
4969
{speaker.avatar ? (
5070
<Image
5171
src={speaker.avatar}
52-
// TODO: Get a placeholder blur URL with plaiceholder
53-
placeholder="blur"
72+
// the placeholder without an additional blur NextImage adds actually fits better here
73+
placeholder={avatarPlaceholder! as `data:image/${string}`}
5474
alt=""
5575
width={176}
5676
height={176}

src/app/conf/2025/components/top-minds/index.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import Image from "next/image"
44
import type { StaticImageData } from "next/image"
55

66
import { Button } from "@/app/conf/_design-system/button"
7-
import { GET_TICKETS_LINK } from "../../links"
87
import { StripesDecoration } from "@/app/conf/_design-system/stripes-decoration"
98
import {
109
SocialIconType,
1110
SocialIcon,
1211
urlForUser,
1312
} from "@/app/conf/_design-system/social-icon"
13+
import { getBase64Placeholder } from "../../../_design-system/utils/get-base64-placeholder"
1414

1515
const previousConfSpeakers = {
1616
benjie: {
@@ -127,14 +127,24 @@ export interface TopMindCardProps {
127127
socials: Partial<Record<SocialIconType, string>>
128128
}
129129

130-
function TopMindCard({
130+
async function TopMindCard({
131131
name,
132132
title,
133133
src,
134134
className,
135135
stripes,
136136
socials,
137137
}: TopMindCardProps) {
138+
let avatarPlaceholder: string | undefined
139+
140+
try {
141+
if (typeof src === "string") {
142+
avatarPlaceholder = await getBase64Placeholder(src)
143+
}
144+
} catch {
145+
// this happens only in development
146+
}
147+
138148
return (
139149
<article
140150
className={clsx(
@@ -146,8 +156,7 @@ function TopMindCard({
146156
<div className="absolute inset-0 z-[1] bg-sec-light opacity-90 mix-blend-multiply" />
147157
<Image
148158
src={src}
149-
// TODO: Get a placeholder blur URL with plaiceholder
150-
placeholder="blur"
159+
placeholder={avatarPlaceholder! as `data:image/${string}`}
151160
alt=""
152161
width={312}
153162
height={312}

src/app/conf/2025/schedule/[id]/page.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ export function generateStaticParams() {
5252
return schedule.filter(s => s.id).map(s => ({ id: s.id }))
5353
}
5454

55+
export const getStaticProps = async () => {
56+
const placeholders = speakers.map(speaker => lqip)
57+
58+
return {
59+
props: {
60+
placeholders
61+
}
62+
}
63+
}
64+
5565
export default function SessionPage({ params }: SessionProps) {
5666
const session = schedule.find(s => s.id === params.id)
5767
if (!session) {

src/app/conf/2025/speakers/[id]/page.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import { Metadata } from "next"
22
import React from "react"
3+
import Image from "next/image"
4+
import clsx from "clsx"
5+
6+
import { SchedSpeaker, ScheduleSession } from "@/app/conf/2023/types"
7+
import { Button } from "@/app/conf/_design-system/button"
38

49
import { previousEditionSessions, speakers, speakerSessions } from "../../_data"
510
import { metadata as layoutMetadata } from "../../layout"
6-
711
import { HERO_MARQUEE_ITEMS } from "../../utils"
812
import { BackLink } from "../../schedule/_components/back-link"
913
import { NavbarPlaceholder } from "../../components/navbar"
1014
import { CtaCardSection } from "../../components/cta-card-section"
11-
import clsx from "clsx"
12-
import { SchedSpeaker, ScheduleSession } from "@/app/conf/2023/types"
13-
import { Button } from "@/app/conf/_design-system/button"
1415
import { MarqueeRows } from "../../components/marquee-rows"
1516
import { GET_TICKETS_LINK } from "../../links"
1617
import { SpeakerTags } from "../../components/speaker-tags"
1718
import { SpeakerLinks } from "../../components/speaker-links"
1819
import { LongSessionCard } from "./long-session-card"
19-
import Image from "next/image"
2020
import { formatDescription } from "../../schedule/[id]/format-description"
21+
import { getBase64Placeholder } from "../../../_design-system/utils/get-base64-placeholder"
2122

2223
type SpeakerProps = { params: { id: string } }
2324

@@ -140,7 +141,7 @@ export default function SpeakerPage({ params }: SpeakerProps) {
140141
)
141142
}
142143

143-
function SpeakerHeader({
144+
async function SpeakerHeader({
144145
speaker,
145146
year,
146147
className,
@@ -149,6 +150,12 @@ function SpeakerHeader({
149150
year: `20${number}`
150151
className?: string
151152
}) {
153+
if (speaker.avatar?.startsWith("//"))
154+
speaker.avatar = `https:${speaker.avatar}`
155+
156+
const avatarPlaceholder =
157+
speaker.avatar && (await getBase64Placeholder(speaker.avatar))
158+
152159
return (
153160
<header
154161
className={clsx("flex justify-between gap-4 max-md:flex-col", className)}
@@ -179,8 +186,8 @@ function SpeakerHeader({
179186
width={464}
180187
height={464}
181188
className="aspect-square size-[464px] object-cover saturate-[0.1] transition-transform max-lg:w-full"
182-
// TODO: Get a placeholder blur URL with plaiceholder
183189
placeholder="blur"
190+
blurDataURL={avatarPlaceholder! as `data:image/${string}`}
184191
/>
185192
</div>
186193
)}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"server-only"
2+
3+
import { getPlaiceholder } from "plaiceholder"
4+
5+
export async function getBase64Placeholder(src: string) {
6+
const image = await fetch(src).then(res => res.arrayBuffer())
7+
return (await getPlaiceholder(Buffer.from(image))).base64
8+
}

0 commit comments

Comments
 (0)