Skip to content

Commit 7bf53ab

Browse files
committed
Add talk thumbnail rendering for slides and download
1 parent 344892c commit 7bf53ab

8 files changed

Lines changed: 5283 additions & 718 deletions

File tree

app/components/TalkThumbnail.tsx

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"use client"
2+
import styled from "styled-components"
3+
4+
//
5+
// Types
6+
//
7+
8+
export type TalkThumbnailData = {
9+
speakerName: string
10+
talkTitle: string
11+
profilePhotoUrl?: string
12+
}
13+
14+
export type TalkThumbnailProps = TalkThumbnailData & {
15+
/** Width in pixels; height is derived for 16:9 (YouTube thumbnail). */
16+
width?: number
17+
className?: string
18+
}
19+
20+
//
21+
// Constants
22+
//
23+
24+
const ASPECT_RATIO = 16 / 9
25+
const DEFAULT_WIDTH = 640
26+
const BG_IMAGE_PATH = "/images/devx-thumbnail-bg.png"
27+
const LOGO_IMAGE_PATH = "/images/sd-devx-brand.png"
28+
29+
//
30+
// Components
31+
//
32+
33+
/**
34+
* Renders a DEVx-style talk video thumbnail matching the DEVxYouTubeThumbnail.svg
35+
* template layout: talk title on the left, circular speaker photo on the right,
36+
* DEVxSD branding at bottom-left, dark silk texture background.
37+
*
38+
* 16:9 aspect ratio (YouTube standard 1280x720).
39+
*/
40+
export function TalkThumbnail({
41+
speakerName,
42+
talkTitle,
43+
profilePhotoUrl,
44+
width = DEFAULT_WIDTH,
45+
className
46+
}: TalkThumbnailProps) {
47+
const height = Math.round(width / ASPECT_RATIO)
48+
49+
// Layout proportions matching the template
50+
const pad = width * 0.06
51+
const photoRadius = Math.round(height * 0.3)
52+
const photoBackdropRadius = photoRadius + Math.round(width * 0.01)
53+
const photoCx = width - pad - photoBackdropRadius
54+
const photoCy = height * 0.44
55+
const titleX = pad
56+
const photoLeftEdge = photoCx - photoBackdropRadius
57+
const titleGap = width * 0.04
58+
const titleMaxWidth = photoLeftEdge - pad - titleGap
59+
const titleFontSize = Math.round(width * 0.058)
60+
const titleY = height * 0.38
61+
const logoWidth = Math.round(width * 0.18)
62+
const logoHeight = Math.round(logoWidth * (582 / 2772))
63+
const logoX = pad
64+
const logoY = height - pad - logoHeight
65+
66+
const titleLines = wrapText(talkTitle || "Your Talk Title", titleMaxWidth, titleFontSize)
67+
68+
return (
69+
<Wrapper className={className} $width={width} $height={height}>
70+
<svg
71+
xmlns="http://www.w3.org/2000/svg"
72+
xmlnsXlink="http://www.w3.org/1999/xlink"
73+
viewBox={`0 0 ${width} ${height}`}
74+
width="100%"
75+
height="100%"
76+
preserveAspectRatio="xMidYMid meet"
77+
aria-label={`Talk thumbnail: ${talkTitle} by ${speakerName}`}
78+
>
79+
<defs>
80+
{profilePhotoUrl ? (
81+
<clipPath id="thumbPhotoClip">
82+
<circle cx={photoCx} cy={photoCy} r={photoRadius} />
83+
</clipPath>
84+
) : null}
85+
</defs>
86+
87+
{/* Dark silk texture background */}
88+
<image
89+
href={BG_IMAGE_PATH}
90+
x={0}
91+
y={0}
92+
width={width}
93+
height={height}
94+
preserveAspectRatio="xMidYMid slice"
95+
/>
96+
97+
{/* Speaker photo circle with light backdrop — right side */}
98+
{profilePhotoUrl ? (
99+
<>
100+
<circle
101+
cx={photoCx}
102+
cy={photoCy}
103+
r={photoBackdropRadius}
104+
fill="rgba(200, 200, 200, 0.25)"
105+
/>
106+
<image
107+
href={profilePhotoUrl}
108+
x={photoCx - photoRadius}
109+
y={photoCy - photoRadius}
110+
width={photoRadius * 2}
111+
height={photoRadius * 2}
112+
clipPath="url(#thumbPhotoClip)"
113+
preserveAspectRatio="xMidYMid slice"
114+
/>
115+
</>
116+
) : (
117+
<circle
118+
cx={photoCx}
119+
cy={photoCy}
120+
r={photoRadius}
121+
fill="rgba(255, 255, 255, 0.06)"
122+
stroke="rgba(255, 255, 255, 0.1)"
123+
strokeWidth={2}
124+
/>
125+
)}
126+
127+
{/* Talk title — large bold white, left side */}
128+
<text
129+
x={titleX}
130+
y={titleY}
131+
fill="#ffffff"
132+
fontFamily="'Chivo', 'Helvetica Neue', Helvetica, Arial, sans-serif"
133+
fontSize={titleFontSize}
134+
fontWeight="400"
135+
>
136+
{titleLines.map((line, i) => (
137+
<tspan key={i} x={titleX} dy={i === 0 ? 0 : titleFontSize * 1.15}>
138+
{line}
139+
</tspan>
140+
))}
141+
</text>
142+
143+
{/* DEVxSD branding — bottom left */}
144+
<image
145+
href={LOGO_IMAGE_PATH}
146+
x={logoX}
147+
y={logoY}
148+
width={logoWidth}
149+
height={logoHeight}
150+
opacity={0.9}
151+
/>
152+
</svg>
153+
</Wrapper>
154+
)
155+
}
156+
157+
const Wrapper = styled.div<{ $width: number; $height: number }>`
158+
width: ${(p) => p.$width}px;
159+
max-width: 100%;
160+
aspect-ratio: 16 / 9;
161+
overflow: hidden;
162+
border-radius: 0.5rem;
163+
border: 1px solid rgba(255, 255, 255, 0.1);
164+
background: #0a0a0a;
165+
166+
svg {
167+
display: block;
168+
width: 100%;
169+
height: auto;
170+
}
171+
`
172+
173+
//
174+
// Functions
175+
//
176+
177+
/** Wrap text into lines that fit within maxWidth, max 3 lines. */
178+
function wrapText(text: string, maxWidth: number, fontSize: number): string[] {
179+
if (!text.trim()) return ["Your Talk Title"]
180+
const words = text.trim().split(/\s+/)
181+
const approxCharWidth = fontSize * 0.52
182+
const maxLines = 3
183+
const lines: string[] = []
184+
let current = ""
185+
186+
for (const word of words) {
187+
const candidate = current ? `${current} ${word}` : word
188+
if (candidate.length * approxCharWidth <= maxWidth) {
189+
current = candidate
190+
} else {
191+
if (current) lines.push(current)
192+
if (lines.length >= maxLines) {
193+
const last = lines[lines.length - 1]
194+
if (last && word) {
195+
lines[lines.length - 1] = last + "..."
196+
}
197+
return lines
198+
}
199+
current = word
200+
}
201+
}
202+
if (current && lines.length < maxLines) lines.push(current)
203+
return lines
204+
}

app/submit-talk/page.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { TextareaInput } from "../components/TextareaInput"
1111
import { RadioInput } from "../components/RadioInput"
1212
import { PageContainer } from "../components/PageContainer"
1313
import { SuccessMessage as SuccessMessageComponent } from "../components/SuccessMessage"
14+
import { TalkThumbnail } from "../components/TalkThumbnail"
1415
import Link from "next/link"
1516

1617
export default function SubmitTalk() {
@@ -23,6 +24,7 @@ export default function SubmitTalk() {
2324
const [userEmail, setUserEmail] = useState("")
2425
const [userFullName, setUserFullName] = useState("")
2526
const [userHandle, setUserHandle] = useState<string | null>(null)
27+
const [profilePhotoUrl, setProfilePhotoUrl] = useState<string | null>(null)
2628
const [profileId, setProfileId] = useState<number | null>(null)
2729
const [profilePhoneNumber, setProfilePhoneNumber] = useState<string | null>(null)
2830
const [isEditingPhone, setIsEditingPhone] = useState(false)
@@ -63,15 +65,16 @@ export default function SubmitTalk() {
6365
const { handle } = getProfileFromCache(user)
6466
setUserHandle(handle)
6567

66-
// Load profile to get full name, profile_id, and phone number
68+
// Load profile to get full name, profile_id, phone number, and profile photo (for thumbnail)
6769
const { data: profile } = await supabaseClient
6870
.from("profiles")
69-
.select("id, full_name, phone_number")
71+
.select("id, full_name, phone_number, profile_photo")
7072
.eq("user_id", user.id)
7173
.single()
7274

7375
if (profile) {
7476
setUserFullName(profile.full_name)
77+
setProfilePhotoUrl(profile.profile_photo || null)
7578
setProfileId(profile.id)
7679
setProfilePhoneNumber(profile.phone_number)
7780
// If profile has phone number, use it; otherwise start with empty
@@ -400,6 +403,26 @@ export default function SubmitTalk() {
400403
required
401404
/>
402405
</Field>
406+
<Field>
407+
<Label>Video thumbnail preview</Label>
408+
<InfoNote>
409+
This is how your talk could look as a YouTube thumbnail. Update your{" "}
410+
{userHandle ? (
411+
<InfoLink href={`/whois?${userHandle}`}>nametag</InfoLink>
412+
) : (
413+
<InfoLink href="/setup">nametag</InfoLink>
414+
)}{" "}
415+
to change your name and photo.
416+
</InfoNote>
417+
<ThumbnailPreviewWrap>
418+
<TalkThumbnail
419+
speakerName={userFullName || "Speaker name"}
420+
talkTitle={formData.talkTitle || "Your talk title"}
421+
profilePhotoUrl={profilePhotoUrl || undefined}
422+
width={640}
423+
/>
424+
</ThumbnailPreviewWrap>
425+
</Field>
403426
</FormSection>
404427

405428
<FormSection>
@@ -579,6 +602,11 @@ const InfoLink = styled(Link)`
579602
}
580603
`
581604

605+
const ThumbnailPreviewWrap = styled.div`
606+
max-width: 100%;
607+
margin-top: 0.5rem;
608+
`
609+
582610
const Label = styled.label`
583611
font-size: 0.875rem;
584612
font-weight: 700;

0 commit comments

Comments
 (0)