Skip to content

Commit bc41602

Browse files
authored
Add talks page type (#132)
* Add talks page type * Add talk slides component
1 parent 17a2f19 commit bc41602

7 files changed

Lines changed: 291 additions & 9 deletions

File tree

scripts/prefix-codes.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { $ } from "bun";
44
import { Code, type Kind } from "../src/utils/code";
55

66
const CONTENT_DIR = `${import.meta.dir}/../src/content`;
7-
const DEFAULT_DIRS = ["blog", "research", "projects"];
7+
const DEFAULT_DIRS = ["blog", "research", "projects", "talks"];
88

99
/**
1010
* Infer kind prefix from directory name
@@ -17,6 +17,8 @@ function inferKind(dir: string): Kind {
1717
return "R";
1818
case "projects":
1919
return "P";
20+
case "talks":
21+
return "T";
2022
default:
2123
throw new Error(`Unknown content directory: ${dir}`);
2224
}
@@ -41,21 +43,21 @@ function extractDate(content: string): string | null {
4143
* Check if filename already has a code prefix (5 chars: kind + hex date followed by --)
4244
*/
4345
function hasCodePrefix(filename: string): boolean {
44-
return /^[BRPbrp][0-9A-Fa-f]{4}--/.test(filename);
46+
return /^[BRPTbrpt][0-9A-Fa-f]{4}--/.test(filename);
4547
}
4648

4749
/**
4850
* Check if filename has an uppercase code prefix
4951
*/
5052
function hasUppercasePrefix(filename: string): boolean {
51-
return /^[BRP][0-9A-F]{4}--/.test(filename) && /[A-F]/.test(filename.substring(1, 5));
53+
return /^[BRPT][0-9A-F]{4}--/.test(filename) && /[A-F]/.test(filename.substring(1, 5));
5254
}
5355

5456
/**
5557
* Check if filename has old 4-char base-36 code prefix (needs migration)
5658
*/
5759
function hasOldCodePrefix(filename: string): boolean {
58-
return /^[0-9A-Za-z]{4}--/.test(filename) && !/^[BRP][0-9A-Fa-f]{4}--/.test(filename);
60+
return /^[0-9A-Za-z]{4}--/.test(filename) && !/^[BRPT][0-9A-Fa-f]{4}--/.test(filename);
5961
}
6062

6163
/**

src/components/Nav.astro

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const tabs: Tab[] = [
1212
{ href: "/blog", keyChar: "b", label: "blog" },
1313
{ href: "/projects", keyChar: "p", label: "projects" },
1414
{ href: "/research", keyChar: "r", label: "research" },
15+
{ href: "/talks", keyChar: "t", label: "talks" },
1516
{ href: "/work", keyChar: "w", label: "work" },
1617
];
1718

src/components/TalkSlides.astro

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
---
2+
import { resolveSlideImageUrl } from "@/utils/images";
3+
4+
interface Slide {
5+
image: string;
6+
timestamp?: number;
7+
}
8+
9+
interface Props {
10+
/**
11+
* Either a number of slides (will look for images 1.png through n.png)
12+
* or an array of slide objects with image paths and optional timestamps
13+
*/
14+
slides: number | Slide[];
15+
/**
16+
* Base path for slide images (e.g., "/talks/codegen-in-rust")
17+
*/
18+
basePath: string;
19+
/**
20+
* ID for the talk (used for namespacing)
21+
*/
22+
talkId: string;
23+
}
24+
25+
const { slides, basePath, talkId } = Astro.props;
26+
27+
// Normalize slides to array format
28+
const slideList: Slide[] = typeof slides === "number"
29+
? Array.from({ length: slides }, (_, i) => ({
30+
image: `codegen-${i + 1}.png`,
31+
}))
32+
: slides;
33+
34+
// Resolve image URLs with local-first fallback
35+
// Priority: 1) Local file in /assets/, 2) R2 URL from manifest, 3) Fallback path
36+
const resolvedSlides = await Promise.all(
37+
slideList.map(async (slide) => {
38+
const resolvedUrl = await resolveSlideImageUrl(basePath, slide.image);
39+
// Fallback: if not resolved, try /assets/{basePath} (should rarely happen)
40+
const normalizedBase = basePath.startsWith("/") ? basePath.slice(1) : basePath;
41+
return {
42+
...slide,
43+
url: resolvedUrl ?? `/assets/${normalizedBase}/${slide.image}`,
44+
};
45+
})
46+
);
47+
48+
const totalSlides = resolvedSlides.length;
49+
const containerId = `talk-slides-${talkId}`;
50+
---
51+
52+
<div id={containerId} class="talk-slides not-prose" tabindex="0">
53+
<div class="slide-container bg-1 border-2 border-fg-2 relative" style="cursor: pointer;">
54+
<img
55+
src={resolvedSlides[0].url}
56+
alt="Slide 1"
57+
class="slide-image w-full h-auto"
58+
/>
59+
</div>
60+
61+
<div class="controls mt-2 flex items-center justify-between gap-2">
62+
<button
63+
class="btn-prev bg-2 text-accent hocus:bg-0 hocus:invert px-1 py-0.5 border-2 border-fg-0 outline-none font-medium disabled:opacity-50 disabled:cursor-not-allowed"
64+
disabled
65+
>
66+
← Prev
67+
</button>
68+
69+
<div class="slide-counter text-fg-1 font-mono">
70+
1 / {totalSlides}
71+
</div>
72+
73+
<button
74+
class="btn-next bg-2 text-accent hocus:bg-0 hocus:invert px-1 py-0.5 border-2 border-fg-0 outline-none font-medium disabled:opacity-50 disabled:cursor-not-allowed"
75+
>
76+
Next →
77+
</button>
78+
</div>
79+
80+
<div class="keyboard-hints mt-1 text-fg-2 text-sm text-center">
81+
<kbd class="px-1 bg-1 border border-fg-2">←</kbd>
82+
<kbd class="px-1 bg-1 border border-fg-2">→</kbd>
83+
<kbd class="px-1 bg-1 border border-fg-2">Space</kbd>
84+
to navigate •
85+
<kbd class="px-1 bg-1 border border-fg-2">Home</kbd>
86+
<kbd class="px-1 bg-1 border border-fg-2">End</kbd>
87+
to jump
88+
</div>
89+
</div>
90+
91+
<script define:vars={{ containerId, resolvedSlides, totalSlides }}>
92+
const container = document.getElementById(containerId);
93+
if (!container) throw new Error(`Container ${containerId} not found`);
94+
95+
const img = container.querySelector(".slide-image");
96+
const counter = container.querySelector(".slide-counter");
97+
const btnPrev = container.querySelector(".btn-prev");
98+
const btnNext = container.querySelector(".btn-next");
99+
const slideContainer = container.querySelector(".slide-container");
100+
101+
let currentSlide = 0;
102+
103+
function updateSlide() {
104+
const slide = resolvedSlides[currentSlide];
105+
img.src = slide.url;
106+
img.alt = `Slide ${currentSlide + 1}`;
107+
counter.textContent = `${currentSlide + 1} / ${totalSlides}`;
108+
btnPrev.disabled = currentSlide === 0;
109+
btnNext.disabled = currentSlide === totalSlides - 1;
110+
}
111+
112+
function goToSlide(index) {
113+
currentSlide = Math.max(0, Math.min(index, totalSlides - 1));
114+
updateSlide();
115+
}
116+
117+
// Button clicks
118+
btnPrev.addEventListener("click", () => goToSlide(currentSlide - 1));
119+
btnNext.addEventListener("click", () => goToSlide(currentSlide + 1));
120+
slideContainer.addEventListener("click", () => goToSlide(currentSlide + 1));
121+
122+
// Keyboard events
123+
container.addEventListener("keydown", (evt) => {
124+
if (evt.key === "ArrowRight" || evt.key === " ") {
125+
evt.preventDefault();
126+
goToSlide(currentSlide + 1);
127+
} else if (evt.key === "ArrowLeft") {
128+
evt.preventDefault();
129+
goToSlide(currentSlide - 1);
130+
} else if (evt.key === "Home") {
131+
evt.preventDefault();
132+
goToSlide(0);
133+
} else if (evt.key === "End") {
134+
evt.preventDefault();
135+
goToSlide(totalSlides - 1);
136+
}
137+
});
138+
</script>
139+
140+
<style>
141+
.talk-slides {
142+
max-width: 100%;
143+
margin: 2lh auto;
144+
}
145+
146+
.slide-container {
147+
position: relative;
148+
aspect-ratio: 16 / 9;
149+
overflow: hidden;
150+
}
151+
152+
.slide-image {
153+
position: absolute;
154+
top: 0;
155+
left: 0;
156+
width: 100%;
157+
height: 100%;
158+
object-fit: contain;
159+
}
160+
161+
kbd {
162+
font-family: monospace;
163+
font-size: 0.875em;
164+
}
165+
</style>

src/content.config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ const research = defineCollection({
3737
}),
3838
});
3939

40+
const talks = defineCollection({
41+
loader: glob({ base: "./src/content/talks", pattern: "**/*.{md,mdx}" }),
42+
schema: z.object({
43+
title: z.string(),
44+
description: z.string().optional(),
45+
date: z.coerce.date(),
46+
event: z.string(),
47+
}),
48+
});
49+
4050
const pages = defineCollection({
4151
loader: glob({ base: "./src/content/pages", pattern: "**/*.{md,mdx}" }),
4252
schema: z.object({
@@ -51,4 +61,4 @@ const devtools = defineCollection({
5161
}),
5262
});
5363

54-
export const collections = { blog, projects, research, pages, devtools };
64+
export const collections = { blog, projects, research, pages, devtools, talks };

src/pages/talks/[slug].astro

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
import { getCollection, render } from "astro:content";
3+
import FormattedDate from "@/components/FormattedDate.astro";
4+
import Link from "@/components/Link.astro";
5+
import MDContent from "@/components/MDContent.astro";
6+
import Layout from "@/layouts/Layout.astro";
7+
import { Code } from "@/utils/code";
8+
9+
export async function getStaticPaths() {
10+
const talks = await getCollection("talks");
11+
return talks.map((talk) => ({
12+
params: { slug: talk.id },
13+
props: talk,
14+
}));
15+
}
16+
17+
type Props = Awaited<ReturnType<typeof getStaticPaths>>[number]["props"];
18+
19+
const talk = Astro.props;
20+
const { Content } = await render(talk);
21+
22+
// Extract code from filename
23+
const code = Code.fromId(talk.id);
24+
---
25+
26+
<Layout title={`${talk.data.title} - Just Be`} description={talk.data.description}>
27+
<article class="px-4 max-w-article mx-auto">
28+
<header class="mb-2lh grid grid-cols-[1fr_auto] gap-x-2 not-prose">
29+
<h1 class="mb-0 font-bold">{talk.data.title}</h1>
30+
31+
<div class="flex gap-[1ch] justify-end">
32+
<span class="text-fg-2">{talk.data.event}</span>
33+
</div>
34+
35+
<div class="flex gap-[1ch]">
36+
<span class="text-fg-2">{code}</span>
37+
<span class="opacity-25">|</span>
38+
<FormattedDate date={talk.data.date} />
39+
<span class="opacity-25">|</span>
40+
<span class="text-fg-2">{talk.data.location}</span>
41+
</div>
42+
</header>
43+
44+
<div class="max-w-none">
45+
<MDContent Content={Content} components={{ a: Link }} />
46+
</div>
47+
48+
<div class="text-fg-0 mt-2lh">
49+
<a href="/talks" class="text-fg-0 link font-bold focus-visible:outline-none h-1lh">
50+
← Back to talks
51+
</a>
52+
</div>
53+
</article>
54+
</Layout>
55+
56+
<style>
57+
.link:hover,
58+
.link:focus {
59+
padding-left: 0.5rem;
60+
padding-right: 0.5rem;
61+
margin-left: -0.5rem;
62+
margin-right: -0.5rem;
63+
background-color: var(--background0);
64+
filter: invert(1);
65+
}
66+
</style>

src/pages/talks/index.astro

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
import { getCollection } from "astro:content";
3+
import Layout from "@/layouts/Layout.astro";
4+
import { Code } from "@/utils/code";
5+
6+
const talks = (await getCollection("talks"))
7+
.map((talk) => ({ ...talk, code: Code.fromId(talk.id) }))
8+
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
9+
---
10+
11+
<Layout title="Talks - Just Be">
12+
<header class="mb-2">
13+
<h1 class="font-bold">~/talks</h1>
14+
</header>
15+
16+
<ul class="grid grid-cols-[auto_auto_1fr] gap-x-2">
17+
{
18+
talks.map((talk) => {
19+
const date = `${talk.data.date.getFullYear()}-${String(talk.data.date.getMonth() + 1).padStart(2, "0")}-${String(talk.data.date.getDate()).padStart(2, "0")}`;
20+
return (
21+
<li class="contents before:content-none">
22+
<a
23+
href={`/talks/${talk.id}/`}
24+
class="bg-0 hocus:invert group col-span-3 -mx-[1ch] grid grid-cols-subgrid px-[1ch] outline-none"
25+
>
26+
<code class="text-fg-2 group-hocus:text-accent">{talk.code.toString()}</code>
27+
<span class="text-fg-2 group-hocus:text-accent whitespace-nowrap">{date}</span>
28+
<h2 class="font-bold whitespace-nowrap">{talk.data.title}</h2>
29+
</a>
30+
</li>
31+
);
32+
})
33+
}
34+
</ul>
35+
</Layout>

0 commit comments

Comments
 (0)