Skip to content
Open
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
204 changes: 204 additions & 0 deletions app/components/TalkThumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Wrapper className={className} $width={width} $height={height}>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox={`0 0 ${width} ${height}`}
width="100%"
height="100%"
preserveAspectRatio="xMidYMid meet"
aria-label={`Talk thumbnail: ${talkTitle} by ${speakerName}`}
>
<defs>
{profilePhotoUrl ? (
<clipPath id="thumbPhotoClip">
<circle cx={photoCx} cy={photoCy} r={photoRadius} />
</clipPath>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded SVG clipPath ID causes multi-instance collision

Medium Severity

The clipPath uses a hardcoded id="thumbPhotoClip". Since TalkThumbnail is an exported reusable component, rendering multiple instances on the same page (e.g., a talk listing) would create duplicate DOM IDs. The clipPath="url(#thumbPhotoClip)" reference would resolve to the first instance's clip circle, causing all other thumbnails' photos to be clipped incorrectly. A unique ID per instance (e.g., using useId) would fix this.

Fix in Cursor Fix in Web

) : null}
</defs>

{/* Dark silk texture background */}
<image
href={BG_IMAGE_PATH}
x={0}
y={0}
width={width}
height={height}
preserveAspectRatio="xMidYMid slice"
/>

{/* Speaker photo circle with light backdrop — right side */}
{profilePhotoUrl ? (
<>
<circle
cx={photoCx}
cy={photoCy}
r={photoBackdropRadius}
fill="rgba(200, 200, 200, 0.25)"
/>
<image
href={profilePhotoUrl}
x={photoCx - photoRadius}
y={photoCy - photoRadius}
width={photoRadius * 2}
height={photoRadius * 2}
clipPath="url(#thumbPhotoClip)"
preserveAspectRatio="xMidYMid slice"
/>
</>
) : (
<circle
cx={photoCx}
cy={photoCy}
r={photoRadius}
fill="rgba(255, 255, 255, 0.06)"
stroke="rgba(255, 255, 255, 0.1)"
strokeWidth={2}
/>
)}

{/* Talk title — large bold white, left side */}
<text
x={titleX}
y={titleY}
fill="#ffffff"
fontFamily="'Chivo', 'Helvetica Neue', Helvetica, Arial, sans-serif"
fontSize={titleFontSize}
fontWeight="400"
>
{titleLines.map((line, i) => (
<tspan key={i} x={titleX} dy={i === 0 ? 0 : titleFontSize * 1.15}>
{line}
</tspan>
))}
</text>

{/* DEVxSD branding — bottom left */}
<image
href={LOGO_IMAGE_PATH}
x={logoX}
y={logoY}
width={logoWidth}
height={logoHeight}
opacity={0.9}
/>
</svg>
</Wrapper>
)
}

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
}
32 changes: 30 additions & 2 deletions app/submit-talk/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -23,6 +24,7 @@ export default function SubmitTalk() {
const [userEmail, setUserEmail] = useState("")
const [userFullName, setUserFullName] = useState("")
const [userHandle, setUserHandle] = useState<string | null>(null)
const [profilePhotoUrl, setProfilePhotoUrl] = useState<string | null>(null)
const [profileId, setProfileId] = useState<number | null>(null)
const [profilePhoneNumber, setProfilePhoneNumber] = useState<string | null>(null)
const [isEditingPhone, setIsEditingPhone] = useState(false)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -400,6 +403,26 @@ export default function SubmitTalk() {
required
/>
</Field>
<Field>
<Label>Video thumbnail preview</Label>
<InfoNote>
This is how your talk could look as a YouTube thumbnail. Update your{" "}
{userHandle ? (
<InfoLink href={`/whois?${userHandle}`}>nametag</InfoLink>
) : (
<InfoLink href="/setup">nametag</InfoLink>
)}{" "}
to change your name and photo.
</InfoNote>
<ThumbnailPreviewWrap>
<TalkThumbnail
speakerName={userFullName || "Speaker name"}
talkTitle={formData.talkTitle || "Your talk title"}
profilePhotoUrl={profilePhotoUrl || undefined}
width={640}
/>
</ThumbnailPreviewWrap>
</Field>
</FormSection>

<FormSection>
Expand Down Expand Up @@ -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;
Expand Down
Loading