diff --git a/app/components/TalkThumbnail.tsx b/app/components/TalkThumbnail.tsx
new file mode 100644
index 0000000..2007b7c
--- /dev/null
+++ b/app/components/TalkThumbnail.tsx
@@ -0,0 +1,204 @@
+"use client"
+import styled from "styled-components"
+
+//
+// Types
+//
+
+export type TalkThumbnailData = {
+ speakerName: string
+ talkTitle: string
+ profilePhotoUrl?: string
+}
+
+export type TalkThumbnailProps = TalkThumbnailData & {
+ /** Width in pixels; height is derived for 16:9 (YouTube thumbnail). */
+ width?: number
+ className?: string
+}
+
+//
+// Constants
+//
+
+const ASPECT_RATIO = 16 / 9
+const DEFAULT_WIDTH = 640
+const BG_IMAGE_PATH = "/images/devx-thumbnail-bg.png"
+const LOGO_IMAGE_PATH = "/images/sd-devx-brand.png"
+
+//
+// Components
+//
+
+/**
+ * Renders a DEVx-style talk video thumbnail matching the DEVxYouTubeThumbnail.svg
+ * template layout: talk title on the left, circular speaker photo on the right,
+ * DEVxSD branding at bottom-left, dark silk texture background.
+ *
+ * 16:9 aspect ratio (YouTube standard 1280x720).
+ */
+export function TalkThumbnail({
+ speakerName,
+ talkTitle,
+ profilePhotoUrl,
+ width = DEFAULT_WIDTH,
+ className
+}: TalkThumbnailProps) {
+ const height = Math.round(width / ASPECT_RATIO)
+
+ // Layout proportions matching the template
+ const pad = width * 0.06
+ const photoRadius = Math.round(height * 0.3)
+ const photoBackdropRadius = photoRadius + Math.round(width * 0.01)
+ const photoCx = width - pad - photoBackdropRadius
+ const photoCy = height * 0.44
+ const titleX = pad
+ const photoLeftEdge = photoCx - photoBackdropRadius
+ const titleGap = width * 0.04
+ const titleMaxWidth = photoLeftEdge - pad - titleGap
+ const titleFontSize = Math.round(width * 0.058)
+ const titleY = height * 0.38
+ const logoWidth = Math.round(width * 0.18)
+ const logoHeight = Math.round(logoWidth * (582 / 2772))
+ const logoX = pad
+ const logoY = height - pad - logoHeight
+
+ const titleLines = wrapText(talkTitle || "Your Talk Title", titleMaxWidth, titleFontSize)
+
+ return (
+
+
+
+ )
+}
+
+const Wrapper = styled.div<{ $width: number; $height: number }>`
+ width: ${(p) => p.$width}px;
+ max-width: 100%;
+ aspect-ratio: 16 / 9;
+ overflow: hidden;
+ border-radius: 0.5rem;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: #0a0a0a;
+
+ svg {
+ display: block;
+ width: 100%;
+ height: auto;
+ }
+`
+
+//
+// Functions
+//
+
+/** Wrap text into lines that fit within maxWidth, max 3 lines. */
+function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
+ if (!text.trim()) return ["Your Talk Title"]
+ const words = text.trim().split(/\s+/)
+ const approxCharWidth = fontSize * 0.52
+ const maxLines = 3
+ const lines: string[] = []
+ let current = ""
+
+ for (const word of words) {
+ const candidate = current ? `${current} ${word}` : word
+ if (candidate.length * approxCharWidth <= maxWidth) {
+ current = candidate
+ } else {
+ if (current) lines.push(current)
+ if (lines.length >= maxLines) {
+ const last = lines[lines.length - 1]
+ if (last && word) {
+ lines[lines.length - 1] = last + "..."
+ }
+ return lines
+ }
+ current = word
+ }
+ }
+ if (current && lines.length < maxLines) lines.push(current)
+ return lines
+}
diff --git a/app/submit-talk/page.tsx b/app/submit-talk/page.tsx
index 3849fac..4840601 100644
--- a/app/submit-talk/page.tsx
+++ b/app/submit-talk/page.tsx
@@ -11,6 +11,7 @@ import { TextareaInput } from "../components/TextareaInput"
import { RadioInput } from "../components/RadioInput"
import { PageContainer } from "../components/PageContainer"
import { SuccessMessage as SuccessMessageComponent } from "../components/SuccessMessage"
+import { TalkThumbnail } from "../components/TalkThumbnail"
import Link from "next/link"
export default function SubmitTalk() {
@@ -23,6 +24,7 @@ export default function SubmitTalk() {
const [userEmail, setUserEmail] = useState("")
const [userFullName, setUserFullName] = useState("")
const [userHandle, setUserHandle] = useState(null)
+ const [profilePhotoUrl, setProfilePhotoUrl] = useState(null)
const [profileId, setProfileId] = useState(null)
const [profilePhoneNumber, setProfilePhoneNumber] = useState(null)
const [isEditingPhone, setIsEditingPhone] = useState(false)
@@ -63,15 +65,16 @@ export default function SubmitTalk() {
const { handle } = getProfileFromCache(user)
setUserHandle(handle)
- // Load profile to get full name, profile_id, and phone number
+ // Load profile to get full name, profile_id, phone number, and profile photo (for thumbnail)
const { data: profile } = await supabaseClient
.from("profiles")
- .select("id, full_name, phone_number")
+ .select("id, full_name, phone_number, profile_photo")
.eq("user_id", user.id)
.single()
if (profile) {
setUserFullName(profile.full_name)
+ setProfilePhotoUrl(profile.profile_photo || null)
setProfileId(profile.id)
setProfilePhoneNumber(profile.phone_number)
// If profile has phone number, use it; otherwise start with empty
@@ -400,6 +403,26 @@ export default function SubmitTalk() {
required
/>
+
+
+
+ This is how your talk could look as a YouTube thumbnail. Update your{" "}
+ {userHandle ? (
+ nametag
+ ) : (
+ nametag
+ )}{" "}
+ to change your name and photo.
+
+
+
+
+
@@ -579,6 +602,11 @@ const InfoLink = styled(Link)`
}
`
+const ThumbnailPreviewWrap = styled.div`
+ max-width: 100%;
+ margin-top: 0.5rem;
+`
+
const Label = styled.label`
font-size: 0.875rem;
font-weight: 700;
diff --git a/app/talk-thumbnail-gen/page.tsx b/app/talk-thumbnail-gen/page.tsx
new file mode 100644
index 0000000..3a773cd
--- /dev/null
+++ b/app/talk-thumbnail-gen/page.tsx
@@ -0,0 +1,501 @@
+"use client"
+import styled from "styled-components"
+import { useState, useRef, useCallback } from "react"
+import { supabaseClient } from "../../lib/supabaseClient"
+import { TalkThumbnail } from "../components/TalkThumbnail"
+import { TextInput } from "../components/TextInput"
+import { Button } from "../components/Button"
+import { PotionBackground } from "../components/PotionBackground"
+import { PageContainer } from "../components/PageContainer"
+
+//
+// Constants
+//
+
+const THUMBNAIL_WIDTH = 1280
+const THUMBNAIL_HEIGHT = 720
+
+//
+// Components
+//
+
+export default function TalkThumbnailGen() {
+ const [talkTitle, setTalkTitle] = useState("")
+ const [handle, setHandle] = useState("")
+ const [photoUrl, setPhotoUrl] = useState(null)
+ const [photoSource, setPhotoSource] = useState<"none" | "upload" | "handle">("none")
+ const [handleLoading, setHandleLoading] = useState(false)
+ const [handleError, setHandleError] = useState(null)
+ const fileInputRef = useRef(null)
+ const svgContainerRef = useRef(null)
+
+ const handleFileUpload = useCallback(
+ (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+
+ const blobUrl = URL.createObjectURL(file)
+ setPhotoUrl(blobUrl)
+ setPhotoSource("upload")
+ setHandle("")
+ setHandleError(null)
+ },
+ [photoUrl, photoSource]
+ )
+
+ const handleLookup = useCallback(async () => {
+ if (!handle.trim()) return
+
+ setHandleLoading(true)
+ setHandleError(null)
+
+ try {
+ const { data: profile, error } = await supabaseClient
+ .from("profiles")
+ .select("full_name, profile_photo")
+ .eq("handle", handle.trim().toLowerCase())
+ .single()
+
+ if (error || !profile) {
+ setHandleError(`No profile found for @${handle.trim()}`)
+ return
+ }
+
+ if (profile.profile_photo) {
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+ setPhotoUrl(profile.profile_photo)
+ setPhotoSource("handle")
+ } else {
+ setHandleError(`@${handle.trim()} has no profile photo`)
+ }
+ } catch {
+ setHandleError("Failed to look up profile")
+ } finally {
+ setHandleLoading(false)
+ }
+ }, [handle, photoUrl, photoSource])
+
+ const clearPhoto = useCallback(() => {
+ if (photoUrl && photoSource === "upload") {
+ URL.revokeObjectURL(photoUrl)
+ }
+ setPhotoUrl(null)
+ setPhotoSource("none")
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }, [photoUrl, photoSource])
+
+ const downloadRaster = useCallback(
+ async (format: "png" | "jpg") => {
+ const svgEl = svgContainerRef.current?.querySelector("svg")
+ if (!svgEl) return
+
+ const svgString = await buildEmbeddedSvgString(svgEl)
+ const svgBlob = new Blob([svgString], { type: "image/svg+xml" })
+ const svgBlobUrl = URL.createObjectURL(svgBlob)
+
+ const img = new Image()
+ img.width = THUMBNAIL_WIDTH
+ img.height = THUMBNAIL_HEIGHT
+
+ img.onload = () => {
+ const canvas = document.createElement("canvas")
+ canvas.width = THUMBNAIL_WIDTH
+ canvas.height = THUMBNAIL_HEIGHT
+ const ctx = canvas.getContext("2d")
+ if (!ctx) return
+
+ // JPG has no transparency — fill white first
+ if (format === "jpg") {
+ ctx.fillStyle = "#000000"
+ ctx.fillRect(0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
+ }
+
+ ctx.drawImage(img, 0, 0, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
+ URL.revokeObjectURL(svgBlobUrl)
+
+ const mimeType = format === "jpg" ? "image/jpeg" : "image/png"
+ // Quality 0.92 for JPG keeps it well under 2 MB
+ const quality = format === "jpg" ? 0.92 : undefined
+
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) return
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = makeFilename(talkTitle, format)
+ a.click()
+ URL.revokeObjectURL(url)
+ },
+ mimeType,
+ quality
+ )
+ }
+
+ img.src = svgBlobUrl
+ },
+ [talkTitle]
+ )
+
+ return (
+ <>
+
+
+
+
+
+ Talk Thumbnail Generator
+ Create a YouTube thumbnail for your DEVx talk
+
+
+
+
+ setTalkTitle(e.target.value)}
+ placeholder="Enter your talk title"
+ />
+
+
+
+
+
+
+
+
+ or
+
+
+
+
+ @
+ setHandle(e.target.value)}
+ placeholder="username"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleLookup()
+ }
+ }}
+ />
+
+
+ {handleError && {handleError}}
+
+
+
+ {photoUrl && (
+
+
+ Photo loaded
+ {photoSource === "handle" ? ` from @${handle}` : " from upload"}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ 1280 x 720 px · under 2 MB · YouTube ready
+
+
+ >
+ )
+}
+
+//
+// Styled Components
+//
+
+const BackgroundContainer = styled.section`
+ background-color: #0a0a0a;
+ position: fixed;
+ height: 100vh;
+ width: 100vw;
+ top: 0;
+ left: 0;
+ z-index: -1;
+`
+
+const WidePageContainer = styled(PageContainer)`
+ max-width: 960px;
+`
+
+const Container = styled.main`
+ min-height: 100vh;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 2rem 1rem;
+`
+
+const Title = styled.h1`
+ font-size: 2rem;
+ font-weight: 700;
+ color: white;
+ margin: 0;
+ text-align: center;
+`
+
+const Subtitle = styled.p`
+ color: rgba(255, 255, 255, 0.7);
+ margin: -1rem 0 0 0;
+ text-align: center;
+`
+
+const FormSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ width: 100%;
+`
+
+const PhotoRow = styled.div`
+ display: flex;
+ align-items: flex-end;
+ gap: 1.5rem;
+ width: 100%;
+
+ & > div {
+ flex: 1;
+ }
+
+ @media (max-width: 600px) {
+ flex-direction: column;
+ align-items: stretch;
+ }
+`
+
+const PhotoStatusRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+`
+
+const Preview = styled.div`
+ width: 100%;
+ border-radius: 0.5rem;
+ overflow: hidden;
+
+ & > div {
+ width: 100% !important;
+ }
+`
+
+const Field = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+`
+
+const Label = styled.label`
+ font-size: 0.875rem;
+ font-weight: 700;
+ color: rgba(255, 255, 255, 0.9);
+`
+
+const FileInput = styled.input`
+ padding: 0.5rem;
+ background-color: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 0.5rem;
+ color: white;
+ font-size: 0.875rem;
+ cursor: pointer;
+
+ &::file-selector-button {
+ padding: 0.375rem 0.75rem;
+ margin-right: 0.75rem;
+ background-color: rgba(156, 163, 255, 0.2);
+ border: 1px solid rgba(156, 163, 255, 0.4);
+ border-radius: 0.375rem;
+ color: white;
+ cursor: pointer;
+ font-size: 0.8125rem;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background-color: rgba(156, 163, 255, 0.3);
+ }
+ }
+`
+
+const HandleRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+
+ input {
+ flex: 1;
+ }
+`
+
+const HandlePrefix = styled.span`
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 1rem;
+ font-weight: 600;
+ flex-shrink: 0;
+`
+
+const ErrorText = styled.p`
+ margin: 0;
+ color: #ff6b6b;
+ font-size: 0.8125rem;
+`
+
+const PhotoStatus = styled.p`
+ margin: 0;
+ color: rgba(156, 163, 255, 0.9);
+ font-size: 0.8125rem;
+`
+
+const OrDivider = styled.div`
+ text-align: center;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ flex-shrink: 0;
+ padding-bottom: 0.5rem;
+
+ @media (max-width: 600px) {
+ padding-bottom: 0;
+ }
+`
+
+const DownloadRow = styled.div`
+ display: flex;
+ justify-content: center;
+ gap: 0.75rem;
+`
+
+const SpecNote = styled.p`
+ margin: -1rem 0 0 0;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 0.75rem;
+`
+
+//
+// Functions
+//
+
+function makeFilename(title: string, ext: string): string {
+ const slug = (title || "talk-thumbnail")
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")
+ .slice(0, 50)
+ return `${slug}-thumbnail.${ext}`
+}
+
+/**
+ * Convert an image URL (relative, absolute, or blob) to a base64 data URL
+ * by drawing it onto a temporary canvas.
+ */
+async function urlToDataUrl(src: string): Promise {
+ // Already a data URL — return as-is
+ if (src.startsWith("data:")) return src
+
+ // Resolve relative paths to absolute
+ const resolved = src.startsWith("/") ? `${window.location.origin}${src}` : src
+
+ const response = await fetch(resolved)
+ const blob = await response.blob()
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onloadend = () => resolve(reader.result as string)
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+}
+
+/**
+ * Clone the live SVG element, convert every `` to an
+ * embedded base64 data URL, and return the serialized SVG string.
+ * This makes the exported SVG/PNG fully self-contained.
+ */
+async function buildEmbeddedSvgString(svgEl: SVGSVGElement): Promise {
+ const clone = svgEl.cloneNode(true) as SVGSVGElement
+ const images = clone.querySelectorAll("image")
+
+ await Promise.all(
+ Array.from(images).map(async (img) => {
+ const href =
+ img.getAttribute("href") || img.getAttributeNS("http://www.w3.org/1999/xlink", "href")
+ if (!href || href.startsWith("data:")) return
+ try {
+ const dataUrl = await urlToDataUrl(href)
+ img.setAttribute("href", dataUrl)
+ // Remove xlink:href if present to avoid duplicates
+ img.removeAttributeNS("http://www.w3.org/1999/xlink", "href")
+ } catch {
+ // If an image fails to convert, leave the original href
+ }
+ })
+ )
+
+ const serializer = new XMLSerializer()
+ return serializer.serializeToString(clone)
+}
diff --git a/package.json b/package.json
index d2a2fd2..c8c457d 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "next dev",
+ "dev:supabase": "supabase start",
"build": "next build",
"start": "next start",
"lint": "next lint",
diff --git a/public/images/devx-thumbnail-bg.png b/public/images/devx-thumbnail-bg.png
new file mode 100644
index 0000000..d6b527d
Binary files /dev/null and b/public/images/devx-thumbnail-bg.png differ
diff --git a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
index 9b4deeb..ba24890 100644
--- a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
+++ b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/metadata.json
@@ -1,6 +1,6 @@
{
- "title": "Houdini Overview and Custom Properties Dive",
- "description": "A lightning talk covering the Houdini initive and then narrowing in on how we can use the full power of CSS custom properties.",
- "author": "AJ Caldwell",
- "timestamp": "2026-01-23T00:00:00.000Z"
+ "title": "Houdini Overview and Custom Properties Dive",
+ "description": "A lightning talk covering the Houdini initive and then narrowing in on how we can use the full power of CSS custom properties.",
+ "author": "AJ Caldwell",
+ "timestamp": "2026-01-23T00:00:00.000Z"
}
diff --git a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
index 2e2ce0a..1bff922 100644
--- a/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
+++ b/public/slides/houdini-overview-and-custom-properties-dive-aj-caldwell/slides.html
@@ -1,157 +1,181 @@
-
+
-
-
-
- Houdini Overview and Custom Properties Dive
-
-
-
-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-

-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+ Houdini Overview and Custom Properties Dive
+
+
+
+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+

+
+
+
+
+
+
+
+