lw14 ticketing #34444

* mamba init

* shader-updates

* feat: add better bloom

* cleanup

* Fix GLTF model loading to respect original material properties

Signed-off-by: Piotr Goszczyński <pg@tonik.com>

* Fix GLTF model loading to respect original material properties

Signed-off-by: Piotr Goszczyński <pg@tonik.com>

* stash changes

* Update font to Arial in mamba ticket component

Signed-off-by: Piotr Goszczyński <pg@tonik.com>

* Fix wireframe size in debug mode

Signed-off-by: Piotr Goszczyński <pg@tonik.com>

* Fix wireframe size in debug mode by attaching to child mesh instead of model

Signed-off-by: Piotr Goszczyński <pg@tonik.com>

* fix scaling

* fix

* feat: load png with color material

* fix: spline material

* add toggling of effects

* stash

* feat: ticket model

* load better model

* strcture

* fix rotation speed

* rotation

* fix rotation

* new model

* prepare final designs

* add color palette

* feat: add fonts and rendering

* render dynamic texture

* load all textures

* fix: color sace

* feat: secret model

* improve perf

* postprocessing

* fix: output pass

* improve shaders

* increase glitches

* cleanup

* update colors

* remove unused files

* add header

* feat: ui

* fix: raycaster

* feat: add button toggling

* Improve ui

* fix corners positoins

* add tunnel effect

* fix: dates

* add dynamic rocket position rendering

* add different logs

* feat: add migrations and connect to real data

* chore: state cleanup to use reducer

Migrate logic to centralized reducer for ease of debugging

* feat: add hud layout

* add grain effect to hud

* limit glitching, move page to main url

* add vignette effect

* fix: desktop layout

* remove vertical space

* feat: save secret state on ticket upgrade

* feat: make platinum version take priority over secret

* add mobile header

* remove unnecessary allocations

* fix: resize hud

* add page scaling for mobile layouts

* add ticket scaling based on resolution

* adjust mobile styles

* fix: resize texture distortion

* add gauges mobile breaking points

* feat: mobile claim layout

* mobile styles

* Show ticket referer

* rename files

* update copy links

* add partymode part 1

* feat: og images, lw14

* add presence tracking

* add live updates of guages

* Update meetups label

* update label

* username ticket lw14

* add proper subscription on first render

* remove green edge

* feat: remove species & planet from ticket scene

* change urls

* feat/mamba-assets: update assets

* feat: fix shader grain

* add color to shader

* remove vertical space

* fix: og image generation

* fix referal and removing of access token

* make urls nicer

* feat/mamba-assets: dynamic line break + dynamic background

* small adjustments

* add trimming to ogimages

* minor: missing font file

* fix og image line wrapping

* remove dot from meetups

* improve ticket quality

* upss, forgot to commit one file

* feat add dynamic og image linking

* update static site url

* fix low mobile resolution

* add banners

* simplify banner loading

* add missing fonts

* fix name breaking to prioritize spaces

* update quality based on devicePixelRatio

* fix button positioning

* revert: feature flags disabling

* update start date

* limit shader effects and disable axis, potential fix to chrome freeze

* feat: init mamba 01

* create narrow ticket scene

* narrow scene for ticket and fix follow mouse

* fix dates

* alternative version

* updated ticket page

* remove migrations

* remove files

* prettier

* prettier

* Fix TS and prettier issues

* Fix TS issues

* Test

* Remove console log

* fix updating ticket on share

* chore: lw cleanup

* fix ticket layout

* fix chrome crashes

* lower crt intensity

* tunnel improvements

* fix tunnel effect

* chore: z-index and prettier fix

* chore: padding

* fix double tunnel

* chore: font on banners

---------

Signed-off-by: Piotr Goszczyński <pg@tonik.com>
Co-authored-by: dztonik <dz@tonik.com>
Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
Co-authored-by: Francesco Sansalvadore <f.sansalvadore@gmail.com>
This commit is contained in:
Goszczu
2025-03-27 16:55:47 +01:00
committed by GitHub
parent 1524b44fdb
commit 94e919d417
81 changed files with 6118 additions and 395 deletions

View File

@@ -11,6 +11,7 @@ import { QueryClientProvider } from './data/queryClient.client'
import { PageTelemetry } from './telemetry/telemetry.client'
import { ScrollRestoration } from './ui/helpers.scroll.client'
import { ThemeSandbox } from './ui/theme.client'
import { PromoToast } from 'ui-patterns'
/**
* Global providers that wrap the entire app
@@ -27,6 +28,7 @@ function GlobalProviders({ children }: PropsWithChildren) {
<CommandProvider>
<div className="flex flex-col">
<SiteLayout>
<PromoToast />
{children}
<DocsCommandMenu />
</SiteLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

View File

@@ -1,7 +1,7 @@
import React from 'react'
import { ImageResponse } from '@vercel/og'
import { createClient } from '@supabase/supabase-js'
import { themes } from '~/components/LaunchWeek/13/Ticket/ticketThemes'
import { themes } from '~/components/LaunchWeek/14/utils/ticketThemes'
export const runtime = 'edge' // 'nodejs' is the default
export const dynamic = 'force-dynamic' // defaults to auto
@@ -15,11 +15,11 @@ const corsHeaders = {
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const STORAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw13`
const STORAGE_URL = `${SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw14`
// Load custom font
const FONT_URL = `${STORAGE_URL}/assets/font/CircularStd-Book.otf`
const MONO_FONT_URL = `${STORAGE_URL}/assets/font/SourceCodePro-Regular.ttf`
const FONT_URL = `${STORAGE_URL}/assets/font/Nippo-Regular.otf`
const MONO_FONT_URL = `${STORAGE_URL}/assets/font/DepartureMono-Regular.otf`
const font = fetch(new URL(FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
const mono_font = fetch(new URL(MONO_FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
@@ -45,14 +45,14 @@ export async function GET(req: Request, res: Response) {
await supabaseAdminClient
.from(LW_TABLE)
.update({ shared_on_twitter: 'now' })
.eq('launch_week', 'lw13')
.eq('launch_week', 'lw14')
.eq('username', username)
.is('shared_on_twitter', null)
} else if (userAgent?.toLocaleLowerCase().includes('linkedin')) {
await supabaseAdminClient
.from(LW_TABLE)
.update({ shared_on_linkedin: 'now' })
.eq('launch_week', 'lw13')
.eq('launch_week', 'lw14')
.eq('username', username)
.is('shared_on_linkedin', null)
}
@@ -61,61 +61,100 @@ export async function GET(req: Request, res: Response) {
const { data: user, error } = await supabaseAdminClient
.from(LW_MATERIALIZED_VIEW)
.select(
'id, name, metadata, shared_on_twitter, shared_on_linkedin, platinum, secret, role, company, location'
'id, name, metadata, shared_on_twitter, shared_on_linkedin, platinum, secret, role, company, location, ticket_number'
)
.eq('launch_week', 'lw13')
.eq('launch_week', 'lw14')
.eq('username', username)
.maybeSingle()
if (error) console.log('fetch error', error.message)
if (error) console.log('Failed to fetch user. Inner error:', error.message)
if (!user) throw new Error(error?.message ?? 'user not found')
const {
name,
secret,
platinum: isPlatinum,
metadata,
shared_on_twitter: sharedOnTwitter,
shared_on_linkedin: sharedOnLinkedIn,
ticket_number,
} = user
const isDark = metadata.theme !== 'light'
const platinum = isPlatinum ?? (!!sharedOnTwitter && !!sharedOnLinkedIn) ?? false
if (assumePlatinum && !platinum)
return await fetch(`${STORAGE_URL}/assets/platinum_no_meme.jpg`)
if (assumePlatinum && !platinum) return await fetch(`${STORAGE_URL}/images/og-14-platinum.png`)
const seatCode = (466561 + (ticket_number || 0)).toString(36).toUpperCase()
// Generate image and upload to storage.
const ticketType = secret ? 'secret' : platinum ? 'platinum' : 'regular'
const STYLING_CONFIG = (isDark?: boolean) => ({
TICKET_FOREGROUND: themes(isDark)[ticketType].TICKET_FOREGROUND,
const STYLING_CONFIG = () => ({
TICKET_FOREGROUND: themes()[ticketType].TICKET_FOREGROUND,
})
const TICKET_THEME = {
regular: {
color: 'rgb(239, 239, 239)',
background: 'transparent',
},
secret: {
color: 'rgba(44, 244, 148)',
background: 'rgba(44, 244, 148, 0.2)',
},
platinum: {
color: 'rgba(255, 199, 58)',
background: 'rgba(255, 199, 58, 0.2)',
},
}
const fontData = await font
const monoFontData = await mono_font
const OG_WIDTH = 1200
const OG_HEIGHT = 628
const USERNAME_LEFT = 400
const USERNAME_BOTTOM = 100
const USERNAME_WIDTH = 400
const DISPLAY_NAME = name || username
const USERNAME_BOTTOM = 435
const BACKGROUND = (isDark?: boolean) => ({
const BACKGROUND = () => ({
regular: {
LOGO: `${STORAGE_URL}/assets/supabase/supabase-logo-icon.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/ticket-og-bg-regular-${isDark ? 'dark' : 'light'}.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/og-14-regular.png`,
},
platinum: {
LOGO: `${STORAGE_URL}/assets/supabase/supabase-logo-icon.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/ticket-og-bg-platinum.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/og-14-platinum.png`,
},
secret: {
LOGO: `${STORAGE_URL}/assets/supabase/supabase-logo-icon.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/ticket-og-bg-secret.png?v4`,
BACKGROUND_IMG: `${STORAGE_URL}/assets/og-14-secret.png`,
},
})
const usernameToLines = (username: string): string[] => {
const lineLenght = 12
const line1 = username.slice(0, lineLenght).trim().replace(/ /g, '\u00A0')
const line2 = username
.slice(lineLenght, lineLenght * 2)
.trim()
.replace(/ /g, '\u00A0')
let line3 = username
.slice(lineLenght * 2)
.trim()
.replace(/ /g, '\u00A0')
// NOTE: If third line is too long, trim to 8 characters and add '...'
if (line3.length > lineLenght) {
line3 = line3.slice(0, 8) + '...'
}
// NOTE: Only include non-empty lines
return [line1, line2, line3].filter((line) => line.length > 0)
}
const computeBackgroundWidth = (letters: number) => {
return 100 + (letters * 40 + (letters - 1) * 12)
}
const lines = usernameToLines(name ?? username)
const generatedTicketImage = new ImageResponse(
(
<>
@@ -124,16 +163,15 @@ export async function GET(req: Request, res: Response) {
width: '1200px',
height: '628px',
position: 'relative',
fontFamily: '"Circular"',
fontFamily: '"Nippo-Regular"',
overflow: 'hidden',
color: STYLING_CONFIG(isDark).TICKET_FOREGROUND,
color: STYLING_CONFIG().TICKET_FOREGROUND,
display: 'flex',
flexDirection: 'column',
padding: '60px',
justifyContent: 'space-between',
}}
>
{/* Background */}
{/* Background */}
<img
width="1204"
height="634"
@@ -145,42 +183,56 @@ export async function GET(req: Request, res: Response) {
right: '-2px',
zIndex: '0',
backgroundSize: 'cover',
backgroundColor: STYLING_CONFIG().TICKET_FOREGROUND,
}}
src={BACKGROUND(isDark)[ticketType].BACKGROUND_IMG}
src={BACKGROUND()[ticketType].BACKGROUND_IMG}
/>
{/* Name & username */}
{/* Seat number */}
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
flexDirection: 'column',
transform: 'rotate(-90deg)',
fontFamily: '"Nippo-Regular"',
fontSize: '82px',
position: 'absolute',
bottom: USERNAME_BOTTOM,
left: USERNAME_LEFT,
width: USERNAME_WIDTH,
height: 'auto',
overflow: 'hidden',
textOverflow: 'clip',
textAlign: 'left',
letterSpacing: '-0.5px',
marginBottom: '10px',
color: TICKET_THEME[ticketType].color,
top: 70,
right: 135,
}}
>
<p
{seatCode}
</div>
{/* Render each username line */}
{lines.map((line, index) => (
<div
key={index}
style={{
margin: '0',
padding: '0',
fontSize: '54',
lineHeight: '105%',
display: 'flex',
marginBottom: '10px',
position: 'absolute',
bottom: USERNAME_BOTTOM - index * 80,
paddingLeft: '93px',
paddingRight: 0,
left: 27,
height: '61px',
width: `${computeBackgroundWidth(line.length)}px`,
backgroundColor: TICKET_THEME[ticketType].background,
}}
>
{DISPLAY_NAME}
</p>
</div>
<p
style={{
fontFamily: '"DepartureMono-Regular"',
margin: '0',
color: TICKET_THEME[ticketType].color,
padding: '0',
fontSize: '82px',
lineHeight: '56px',
display: 'flex',
}}
>
{line}
</p>
</div>
))}
</div>
</>
),
@@ -189,12 +241,12 @@ export async function GET(req: Request, res: Response) {
height: OG_HEIGHT,
fonts: [
{
name: 'Circular',
name: 'Nippo-Regular',
data: fontData,
style: 'normal',
},
{
name: 'SourceCodePro',
name: 'DepartureMono-Regular',
data: monoFontData,
style: 'normal',
},
@@ -213,7 +265,7 @@ export async function GET(req: Request, res: Response) {
// Upload image to storage.
const { error: storageError } = await supabaseAdminClient.storage
.from('images')
.upload(`launch-week/lw13/og/${ticketType}/${username}.png`, generatedTicketImage.body!, {
.upload(`launch-week/lw14/og/${ticketType}/${username}.png`, generatedTicketImage.body!, {
contentType: 'image/png',
// cacheControl: `${60 * 60 * 24 * 7}`,
cacheControl: `0`,

View File

@@ -1,78 +0,0 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import _announcement from './data/Announcement.json'
import { X } from 'lucide-react'
export interface AnnouncementProps {
show: boolean
text: string
launchDate: string
link: string
badge?: string
}
const announcement = _announcement as AnnouncementProps
interface AnnouncementComponentProps {
show?: boolean
className?: string
link?: string
}
const Announcement = ({
show = true,
className,
children,
link,
}: PropsWithChildren<AnnouncementComponentProps>) => {
const [hidden, setHidden] = useState(true)
const router = useRouter()
const isLaunchWeekSection = router.pathname.includes('launch-week')
// override to hide announcement
if (!show || !announcement.show) return null
// construct the key for the announcement, based on the title text
const announcementKey = 'announcement_' + announcement.text.replace(/ /g, '')
// window.localStorage is kept inside useEffect
// to prevent error
useEffect(function () {
if (!window.localStorage.getItem(announcementKey)) {
return setHidden(false)
}
}, [])
function handleClose(event: any) {
event.stopPropagation()
window.localStorage.setItem(announcementKey, 'hidden')
return setHidden(true)
}
// Always show if on LW section
if (!isLaunchWeekSection && hidden) {
return null
} else {
return (
<div className={['relative z-40 w-full cursor-pointer', className].join(' ')}>
{!isLaunchWeekSection && (
<div
className="absolute z-50 right-4 flex h-full items-center opacity-100 text-white transition-opacity hover:opacity-90"
onClick={handleClose}
>
<X size={16} />
</div>
)}
{children}
<Link href={link ?? announcement.link} className="absolute inset-0 z-40"></Link>
</div>
)
}
}
export default Announcement

View File

@@ -1,7 +0,0 @@
{
"show": true,
"text": "Supabase Launch Week 13: Day 1",
"launchDate": "2023-08-07T09:00:00.000-07:00",
"link": "https://supabase.com/launch-week#day-1",
"badge": "Get your ticket"
}

View File

@@ -3,6 +3,7 @@ import Link from 'next/link'
import { Button } from 'ui'
import SectionContainer from '~/components/Layouts/SectionContainer'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import AnnouncementBadge from '../Announcement/Badge'
const Hero = () => {
const sendTelemetryEvent = useSendTelemetryEvent()
@@ -15,6 +16,14 @@ const Hero = () => {
<div className="mx-auto max-w-2xl lg:col-span-6 lg:flex lg:items-center justify-center text-center">
<div className="relative z-10 lg:h-auto pt-[90px] lg:pt-[90px] lg:min-h-[300px] flex flex-col items-center justify-center sm:mx-auto md:w-3/4 lg:mx-0 lg:w-full gap-4 lg:gap-8">
<div className="flex flex-col items-center">
<div className="z-40 w-full flex justify-center -mt-4 lg:-mt-12 mb-8">
<AnnouncementBadge
className='font-["Departure_Mono"]'
url="/launch-week"
badge="Launch Week 14"
announcement="Claim ticket"
/>
</div>
<h1 className="text-foreground text-4xl sm:text-5xl sm:leading-none lg:text-7xl">
<span className="block text-foreground">Build in a weekend</span>
<span className="text-brand block md:ml-0">Scale to millions</span>

View File

@@ -17,7 +17,7 @@ const LWHeader = ({ className }: { className?: string }) => {
Launch Week <span className="font-mono">12</span>
</h1>
<p className="text-foreground-lighter md:text-xl max-w-xs md:max-w-md">
Join us for a week of new features and find new ways to level up your development
Join us for a week of announcements
</p>
</SectionContainer>
</div>

View File

@@ -1,14 +1,13 @@
import React from 'react'
import { AnimatePresence, m, LazyMotion, domAnimation } from 'framer-motion'
import { AnimatePresence, LazyMotion, domAnimation, m } from 'framer-motion'
import { cn } from 'ui'
import { DEFAULT_TRANSITION, INITIAL_BOTTOM, getAnimation } from '~/lib/animations'
import { LW13_DATE } from '~/lib/constants'
import CanvasSingleMode from '~/components/LaunchWeek/13/Multiplayer/CanvasSingleMode'
import ThreeTicketCanvas from '~/components/LaunchWeek/13/ThreeTicketCanvas'
import SectionContainer from '~/components/Layouts/SectionContainer'
import useConfData from '~/components/LaunchWeek/hooks/use-conf-data'
import useWinningChances from '~/components/LaunchWeek/hooks/useWinningChances'
import SectionContainer from '~/components/Layouts/SectionContainer'
import TicketForm from '~/components/LaunchWeek/13/Ticket/TicketForm'
import TicketSwagCtaBox from '~/components/LaunchWeek/13/Ticket/TicketSwagCtaBox'
@@ -119,15 +118,27 @@ const TicketingFlow = () => {
hasSecretTicket ? (
<p>Got the gold ticket, {FIRST_NAME}!</p>
) : (
<p>Good to see you, {FIRST_NAME}!</p>
<p className='font-["Departure_Mono"] uppercase'>
Good to see you, {FIRST_NAME}!
</p>
)
) : winningChances !== 2 ? (
<>
{hasSecretTicket && <p>{FIRST_NAME}, you're gold!</p>}
{!hasSecretTicket && <p>Good to see you, {FIRST_NAME}!</p>}
{hasSecretTicket && (
<p className='font-["Departure_Mono"] uppercase'>
{FIRST_NAME}, you're gold!
</p>
)}
{!hasSecretTicket && (
<p className='font-["Departure_Mono"] uppercase'>
Good to see you, {FIRST_NAME}!
</p>
)}
</>
) : (
<p>Good to see you, {FIRST_NAME}!</p>
<p className='font-["Departure_Mono"] uppercase'>
Good to see you, {FIRST_NAME}!
</p>
)}
</div>
</div>

View File

@@ -0,0 +1,101 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { KeyboardEvent, ReactNode, useRef } from 'react'
import { useKey } from 'react-use'
import { cn } from 'ui'
import { useCommandMenuOpen } from 'ui-patterns'
const actionButtonVariants = cva(
'pl-1.5 pr-3 py-1.5 rounded shadow flex justify-center items-center gap-2 outline outline-1 outline-offset-[-1px] cursor-pointer flex-nowrap',
{
variants: {
variant: {
primary:
'bg-gradient-to-b from-emerald-400/0 via-emerald-400/30 to-emerald-400/0 shadow-[0px_0px_6px_0px_rgba(44,244,148,0.40)] outline-emerald-400/60',
secondary:
'bg-gradient-to-b from-neutral-600/0 via-neutral-600/30 to-neutral-600/0 shadow-[0px_0px_6px_0px_rgba(255,255,255,0.10)] outline-white/10',
},
},
defaultVariants: {
variant: 'primary',
},
}
)
const iconVariants = cva(
'w-5 h-5 px-2 rounded-sm outline outline-1 outline-offset-[-1px] inline-flex flex-col justify-center items-center gap-2',
{
variants: {
variant: {
primary: 'bg-emerald-950 outline-emerald-400/50',
secondary: 'bg-neutral-800 outline-stone-500/50',
},
},
defaultVariants: {
variant: 'primary',
},
}
)
const textVariants = cva(
'justify-center text-white text-xs leading-[20px] font-normal min-w-[108.25px] [@media(pointer:coarse)]:pl-2 text-nowrap',
{
variants: {
variant: {
primary: '[text-shadow:_0px_0px_10px_rgb(255_255_255_/_1.00)]',
secondary: '[text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.44)]',
},
},
defaultVariants: {
variant: 'primary',
},
}
)
export interface ActionButtonProps extends VariantProps<typeof actionButtonVariants> {
variant: 'primary' | 'secondary' | null | undefined
icon: string
children: ReactNode
onClick?: () => void
className?: string
}
export const ActionButton = ({
variant,
icon,
children,
className,
onClick,
}: ActionButtonProps) => {
const buttonRef = useRef<HTMLDivElement>(null)
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.()
}
}
const isCommandMenuOpen = useCommandMenuOpen()
useKey(icon.toLowerCase(), () => !isCommandMenuOpen && onClick?.(), { event: 'keydown' }, [
isCommandMenuOpen,
onClick,
])
return (
<div
className={cn(actionButtonVariants({ variant }), className)}
onClick={onClick}
onKeyDown={handleKeyPress}
tabIndex={0}
role="button"
aria-label={typeof children === 'string' ? children : undefined}
ref={buttonRef}
>
<div className={cn(iconVariants({ variant }), '[@media(pointer:coarse)]:hidden')}>
<div className="text-center justify-center text-neutral-50 text-xs font-normal leading-none [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)]">
{icon}
</div>
</div>
<div className={textVariants({ variant })}>{children}</div>
</div>
)
}

View File

@@ -0,0 +1,131 @@
import { ReactNode, useEffect, useState } from 'react'
import { cn } from 'ui'
import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn'
export const TicketHeader = ({ children, hidden }: { children: ReactNode; hidden?: boolean }) => {
if (hidden) return null
return (
<SectionContainerWithCn
height="none"
className="grid lg:grid-cols-[127px_1fr_127px] grid-cols-[1fr_1fr] w-full justify-between gap-6 md:gap-8 px-0"
>
{children}
</SectionContainerWithCn>
)
}
const SingleTick = ({ className }: { className?: string }) => {
return (
<div
className={cn(
'w-px h-2.5 bg-neutral-500 shadow-[0px_0px_4px_0px_rgba(255,255,255,0.25)]',
className
)}
/>
)
}
export const TicketHeaderClaim = () => {
return (
<div className="inline-flex justify-start items-center gap-8 w-full order-1 col-span-2 lg:col-auto lg:order-2">
<div className="hidden gap-4 flex-wrap w-full h-2.5 overflow-hidden justify-end flex-shrink flex-1 md:flex">
{Array.from({ length: 7 }).map((_, i) => (
<SingleTick key={i} />
))}
</div>
<div className="text-start md:text-center justify-center text-neutral-500 text-sm font-normal uppercase [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)] lg:text-nowrap flex-grow max-w-[220px] md:max-w-none">
Launch Week is coming. Stay tuned!
</div>
<div className="hidden gap-4 flex-wrap w-full h-2.5 overflow-hidden flex-shrink flex-1 md:flex">
{Array.from({ length: 7 }).map((_, i) => (
<SingleTick key={i} />
))}
</div>
</div>
)
}
interface TicketHeaderRemainingTimeProps {
targetDate: string | Date
}
export const TicketHeaderRemainingTime = ({
targetDate: extTargetDate,
}: TicketHeaderRemainingTimeProps) => {
const [timeLeft, setTimeLeft] = useState<string>('---------------')
useEffect(() => {
const calculateTimeLeft = () => {
const targetDate = new Date(extTargetDate)
const now = new Date()
// Calculate the time difference in milliseconds
const difference = targetDate.getTime() - now.getTime()
if (difference <= 0) {
setTimeLeft('00D:00H:00M:00S')
return
}
const days = Math.floor(difference / (1000 * 60 * 60 * 24))
const hours = Math.floor((difference % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((difference % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((difference % (1000 * 60)) / 1000)
// Format the time left
setTimeLeft(
`${days.toString().padStart(2, '0')}D:${hours.toString().padStart(2, '0')}H:${minutes
.toString()
.padStart(2, '0')}M:${seconds.toString().padStart(2, '0')}S`
)
}
calculateTimeLeft()
const interval = setInterval(calculateTimeLeft, 1000)
return () => clearInterval(interval)
}, [extTargetDate])
return (
<div className="flex-1 order-2 lg:order-1">
<div className="text-justify justify-center text-emerald-400 text-sm font-normal uppercase">
{timeLeft}
</div>
<div className="text-neutral-500 text-justify justify-center text-xs font-normal uppercase">
TIME LEFT
</div>
</div>
)
}
export const TicketHeaderDate = () => {
const [time, setTime] = useState<string>('')
useEffect(() => {
const updateTime = () => {
const now = new Date()
const hours = now.getHours().toString().padStart(2, '0')
const minutes = now.getMinutes().toString().padStart(2, '0')
const seconds = now.getSeconds().toString().padStart(2, '0')
setTime(`${hours}:${minutes}:${seconds}`)
}
updateTime()
const interval = setInterval(updateTime, 1000)
return () => clearInterval(interval)
}, [])
return (
<div className="flex-1 order-3 lg:order-3">
<div className="text-end text-neutral-500 text-sm font-normal uppercase [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)]">
{time}
</div>
<div className="text-end text-neutral-500 text-xs font-normal uppercase [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)] text-nowrap">
LOCAL TIME
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
import Image from 'next/image'
import { ActionButton } from '~/components/LaunchWeek/14/ActionButton'
import {
TicketHeader,
TicketHeaderClaim,
TicketHeaderDate,
TicketHeaderRemainingTime,
} from '~/components/LaunchWeek/14/Header'
import TicketCanvas from '~/components/LaunchWeek/14/TicketCanvas'
import {
TicketClaim,
TicketClaimButtons,
TicketClaimContent,
TicketClaimMessage,
} from '~/components/LaunchWeek/14/TicketClaim'
import TicketCopy from '~/components/LaunchWeek/14/TicketCopy'
import { TicketLayout, TicketLayoutCanvas } from '~/components/LaunchWeek/14/TicketLayout'
import TicketShare from '~/components/LaunchWeek/14/TicketShare'
import { Tunnel } from '~/components/LaunchWeek/14/Tunnel'
import { TicketShareLayout } from '~/components/LaunchWeek/14/TicketShareLayout'
import { useRegistration } from '~/components/LaunchWeek/14/hooks/use-registration'
import useConfData from './hooks/use-conf-data'
const dates = [new Date('2025-03-31T07:00:00.000-08:00')]
export const LwView = () => {
const [state] = useConfData()
const register = useRegistration()
return (
<TicketLayout>
<TicketHeader hidden={true}>
<TicketHeaderRemainingTime targetDate={dates[0]} />
<TicketHeaderClaim />
<TicketHeaderDate />
</TicketHeader>
<TicketLayoutCanvas narrow={true}>
<TicketCanvas narrow={true} onUpgradeToSecret={register.upgradeTicket} />
{state.claimFormState === 'visible' && (
<TicketClaim>
<div className="flex flex-col md:flex-row gap-12 lg:gap-2 grow w-full min-w-full max-w-full pt-16 md:pt-32 md:px-6 lg:px-0 lg:py-0 items-center">
<div className='flex flex-col gap-2 w-full grow justify-center font-["Departure_Mono"]'>
<h1 className="text-4xl uppercase tracking-wide pointer-events-none">
<span className="flex gap-1 items-center">
<Image
src="/images/launchweek/14/logo-pixel-small-dark.png"
width="18"
height="20"
className="w-auto h-5"
alt=""
/>
Supabase
</span>
LaunchWeek 14
<span className="block mt-6 text-foreground-lighter">MAR 31 APR 04</span>
</h1>
<span className="block mt-6 text-foreground-lighter">4 AM / 7 AM PT</span>
</div>
<div className="flex flex-row gap-2 z-10 w-full grow justify-start lg:justify-end">
<TicketClaimContent>
<TicketClaimButtons>
<ActionButton variant="primary" icon="T" onClick={() => register.signIn()}>
CLAIM YOUR TICKET
</ActionButton>
</TicketClaimButtons>
<TicketClaimMessage />
</TicketClaimContent>
</div>
</div>
</TicketClaim>
)}
{state.ticketVisibility && (
<>
<TicketShareLayout narrow>
<TicketCopy />
<TicketShare />
</TicketShareLayout>
</>
)}
<div className="w-full absolute bottom-[55%] md:bottom-[25%] lg:-bottom-8 left-0 right-0 -z-20">
<Tunnel />
</div>
</TicketLayoutCanvas>
</TicketLayout>
)
}

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { motion } from 'framer-motion'
import Tilt from 'vanilla-tilt'
import { useWindowSize } from 'react-use'
import { cn } from 'ui'
import { useBreakpoint } from 'common'
import { AdventDay } from '../data'
import { AdventLink } from '../data/lw14_build_stage'
const BuildCard = ({ day, index }: { day: AdventDay; index: number }) => {
const isTablet = useBreakpoint(1024)
const tiltRef = useRef<HTMLDivElement>(null)
const hiddenRef = useRef<HTMLDivElement>(null)
const { width } = useWindowSize()
const [hiddenHeight, setHiddenHeight] = useState(0)
const transition = { type: 'spring', damping: 10, mass: 0.75, stiffness: 100, delay: index / 15 }
const variants = {
initial: {
rotateY: -90,
opacity: 0,
},
...(day.is_shipped && {
reveal: {
rotateY: 0,
opacity: 1,
transition,
},
}),
}
const isClientLibsCard = day.type === 'clientLibs'
useEffect(() => {
if (tiltRef.current) {
Tilt.init(tiltRef.current, {
glare: false,
max: isClientLibsCard ? 1 : 4,
gyroscope: false,
'full-page-listening': false,
})
}
}, [tiltRef])
useEffect(() => {
if (hiddenRef?.current) {
const { height } = hiddenRef.current.getBoundingClientRect()
setHiddenHeight(height)
}
}, [hiddenRef, width])
return (
<div id={day.id} ref={tiltRef} className="absolute -inset-px group will-change">
<motion.div
className={cn(
'opacity-0 flex flex-col justify-between w-full h-full p-6 rounded-xl bg-surface-75 transition-colors border border-strong hover:border-stronger overflow-hidden',
isClientLibsCard && 'xl:flex-row'
)}
variants={variants}
>
<div className="relative w-full h-full flex flex-col flex-1">
<div className="flex-1 opacity-30 group-hover:opacity-100 transition-opacity text-foreground-light">
{day.icon}
</div>
<div
className={cn(
'relative group-hover:!bottom-0 !ease-[.25,.25,0,1] duration-300 transition-all flex flex-col gap-1'
)}
style={{
bottom: isTablet ? 0 : -hiddenHeight + 'px',
}}
>
<h4 className="text-foreground text-lg leading-6">{day.title}</h4>
<div
ref={hiddenRef}
className="relative z-10 !ease-[.25,.25,0,1] duration-300 transition-opacity opacity-100 lg:opacity-0 group-hover:opacity-100"
style={{
backfaceVisibility: 'hidden',
transform: 'translateZ(0)',
WebkitFontSmoothing: 'subpixel-antialiased',
}}
>
<p className="text-foreground-lighter text-sm">{day.description}</p>
<div className="flex gap-1 mt-3 flex-wrap">
{day.links?.map((link: AdventLink) => (
<Link
key={link.url}
href={link.url}
target={link.target ?? '_self'}
className="px-2 py-1 pointer-events-auto border transition-colors text-foreground-light bg-surface-100 hover:bg-surface-200 rounded text-xs"
>
{link.label}
</Link>
))}
</div>
</div>
</div>
</div>
{isClientLibsCard && (
<div className="flex xl:h-full order-first xl:order-last items-end justify-start xl:justify-end xl:w-2/3 gap-4 xl:gap-6 pb-4 mb-4 xl:pb-0 flex-wrap">
{day.icons?.map((link: AdventLink) => (
<Link
key={link.url}
href={link.url}
title={link.label}
target={link.target ?? '_self'}
className="w-8 h-8 md:w-6 md:h-6 xl:w-14 xl:h-14 xl:p-1 inline-flex items-center justify-center pointer-events-auto transition-colors text-foreground-light hover:text-foreground"
>
{link.icon}
</Link>
))}
</div>
)}
</motion.div>
</div>
)
}
export default BuildCard

View File

@@ -0,0 +1,162 @@
import React from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { ArrowRightIcon } from '@heroicons/react/outline'
import { cn, Skeleton } from 'ui'
import { Edit } from 'lucide-react'
import { useBreakpoint } from 'common'
import { WeekDayProps } from '../data'
import { DayLink } from '.'
import CountdownComponent from 'ui-patterns/Banners/Countdown'
const DaySection = ({ day, className }: { day: WeekDayProps; className?: string }) => {
const isMobile = useBreakpoint('sm')
const cssGroup = 'group/d' + day.d
return (
<section
id={day.id}
className={cn(
'lw-nav-anchor border-b py-8 first:border-t border-muted dark:border-muted/50 text-foreground scroll-mt-16 grid grid-cols-1 gap-4 md:grid-cols-4 xl:grid-cols-3',
className
)}
>
{/* Day title and links */}
<div
id={day.isToday ? 'today' : undefined}
className="flex h-full scroll-mt-10 flex-col gap-4 items-between"
>
<div
className={cn(
'text-sm inline uppercase font-mono dark:text-foreground-muted tracking-[0.1rem]',
day.shipped && '!text-foreground'
)}
>
{day.dd}, {day.date}
</div>
{!!day.links && (
<ul className="flex-1 h-full w-full justify-end grid grid-cols-2 md:flex flex-col gap-1">
{day.links?.map((link) => (
<li key={link.href}>
<DayLink {...link} />
</li>
))}
</ul>
)}
</div>
{/* Day card */}
<div className="flex md:col-span-3 xl:col-span-2">
{day.shipped && day.steps.length > 0 ? (
<Link
href={day.blog!}
className={cn(
`
bg-surface-75
min-h-[210px] group sm:aspect-[3.67/1] relative overflow-hidden flex-1 flex flex-col justify-between
hover:border-strong transition-colors border border-muted
rounded-xl text-2xl bg-contain shadow-sm`,
cssGroup
)}
>
<div className="relative text-foreground-light p-4 sm:px-6 md:py-6 md:px-8 z-20 flex-grow flex flex-col items-start justify-between gap-2 w-full lg:w-1/2 text-left">
<div className="relative w-full flex items-center gap-2 text-sm translate-x-0 !ease-[.24,0,.22,.99] duration-200 group-hover:-translate-x-6 transition-transform">
<Edit className="w-4 min-w-4 group-hover:opacity-0 transition-opacity" />
<span>Blog post</span>
<ArrowRightIcon className="w-4 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<h2 className="text-lg leading-7 [&_strong]:font-normal [&_strong]:text-foreground">
{day.description}
</h2>
</div>
<div className="relative z-10 border-b border-muted/40 sm:border-none w-full order-first aspect-[2/1] sm:aspect-auto sm:absolute sm:inset-0">
<div className="hidden sm:block absolute inset-0 bg-gradient-to-r from-background-surface-75 from-0% via-background-surface-75 via-20% to-transparent to-75% w-full h-full z-0" />
{day.steps[0]?.bg_layers &&
day.steps[0]?.bg_layers?.map((layer, i) => (
<>
{!!layer.img && (
<div
key={`${day.title}-image-${i}?v=3`}
className="absolute transition-opacity inset-0 w-full h-full -z-10 group-hover/d1:opacity-100"
>
<Image
src={!!layer.mobileImg && isMobile ? layer.mobileImg : layer.img}
className={`
hidden dark:block absolute object-cover
w-full h-full z-0 transition-all duration-300
object-center sm:object-right
`}
fill
sizes="100%"
quality={100}
alt={day.title}
/>
</div>
)}
{!!layer.imgLight && (
<div
key={`${day.title}-image-${i}-light`}
className="absolute sm:opacity-90 transition-opacity inset-0 w-full h-full -z-10 group-hover/d1:opacity-100"
>
<Image
src={
!!layer.mobileImgLight && isMobile
? layer.mobileImgLight
: layer.imgLight
}
className={`
dark:hidden absolute md:opacity-50 lg:opacity-100 object-cover
w-full h-full z-0 transition-all duration-300
object-center sm:object-right
`}
fill
sizes="100%"
quality={100}
alt={day.title}
/>
</div>
)}
</>
))}
</div>
</Link>
) : (
<div
className={cn(
`min-h-[210px] group aspect-[3.67/1] relative overflow-hidden flex-1 flex flex-col justify-between
bg-default border border-dashed border-strong dark:border-background-surface-300
rounded-xl p-4 sm:p-6 md:p-8 text-2xl bg-contain`,
cssGroup
)}
>
<div className="flex items-center gap-2 h-5">
<svg
width="17"
height="17"
viewBox="0 0 17 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g opacity="0.5">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.32656 7.58047V5.98047C4.32656 3.77133 6.11742 1.98047 8.32656 1.98047C10.5357 1.98047 12.3266 3.77133 12.3266 5.98047V7.58047C13.2102 7.58047 13.9266 8.29681 13.9266 9.18047V13.1805C13.9266 14.0641 13.2102 14.7805 12.3266 14.7805H4.32656C3.44291 14.7805 2.72656 14.0641 2.72656 13.1805V9.18047C2.72656 8.29681 3.44291 7.58047 4.32656 7.58047ZM10.7266 5.98047V7.58047H5.92656V5.98047C5.92656 4.65499 7.00108 3.58047 8.32656 3.58047C9.65205 3.58047 10.7266 4.65499 10.7266 5.98047Z"
fill="currentColor"
/>
</g>
</svg>
{day.hasCountdown && <CountdownComponent date={day.published_at} showCard={false} />}
</div>
<div>
<Skeleton className="w-full h-3 max-w-xs rounded-full will-change-contents" />
</div>
</div>
)}
</div>
</section>
)
}
export default DaySection

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { IconDocumentation, IconMicSolid, IconProductHunt, IconYoutubeSolid, cn } from 'ui'
import { Music } from 'lucide-react'
import Link from 'next/link'
import { StepLink } from '../data/lw14_data'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
interface DayLink extends StepLink {
className?: string
}
export const DayLink = ({ type, icon, text, href = '', className }: DayLink) => {
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
if (!isMounted) return null
const linkTypes = {
blog: {
icon: IconDocumentation,
text: 'Blog Post',
},
docs: {
icon: IconDocumentation,
text: 'Docs',
},
productHunt: {
icon: IconProductHunt,
text: 'Product Hunt',
},
video: {
icon: IconYoutubeSolid,
text: 'Watch video',
},
podcast: {
icon: Music,
text: 'Podcast',
},
xSpace: {
icon: IconMicSolid,
text: 'X Space',
},
}
const isTargetBlank = () => {
switch (type) {
case 'productHunt':
case 'xSpace':
case 'docs':
return true
}
}
const Icon = icon ?? linkTypes[type].icon
const Text = () => <>{text ?? linkTypes[type]?.text}</>
const Component = type === 'video' ? 'div' : Link
const Trigger = ({ component: Comp, ...props }: any) => (
<Comp
className={cn(
'py-1 flex gap-2 items-center text-foreground-lighter hover:text-foreground transition-colors text-sm',
className
)}
{...props}
>
<span className="w-4 h-4 flex items-center justify-center">
<Icon />
</span>
<Text />
</Comp>
)
if (type === 'video')
return <ExpandableVideo videoId={href} trigger={<Trigger component={Component} />} />
return <Trigger href={href} target={isTargetBlank() ? '_blank' : '_self'} component={Component} />
}
export default {
DayLink,
}

View File

@@ -0,0 +1,4 @@
export { default as mainDays } from './lw14_data'
export type { StepProps, StepLink, WeekDayProps } from './lw14_data'
export { days as buildDays } from './lw14_build_stage'
export type { AdventDay } from './lw14_build_stage'

View File

@@ -0,0 +1,58 @@
// see apps/www/components/LaunchWeek/13/Releases/data/lw13_build_stage.tsx for reference
import { ReactNode } from 'react'
import { type ClassValue } from 'clsx'
export interface AdventDay {
icon?: ReactNode // use svg jsx with 34x34px viewport
className?: ClassValue | string
id: string
title: string
description?: string
is_shipped: boolean
links: AdventLink[]
icons?: AdventLink[]
type?: string
}
export interface AdventLink {
url: string
label?: string
icon?: any
target?: '_blank'
}
export const days: AdventDay[] = [
{
title: '',
description: '',
id: '',
is_shipped: false,
links: [],
icon: null,
},
{
title: '',
description: '',
id: '',
is_shipped: false,
links: [],
icon: null,
},
{
title: '',
description: '',
id: '',
is_shipped: false,
links: [],
icon: null,
},
{
title: '',
description: '',
id: '',
is_shipped: false,
links: [],
icon: null,
},
]

View File

@@ -0,0 +1,182 @@
import { ReactNode } from 'react'
type StepLinkType = 'productHunt' | 'video' | 'docs' | 'xSpace' | 'blog' | 'podcast'
export interface StepLink {
type: StepLinkType
text?: string
icon?: any
href: string
}
export interface StepProps {
title: string
icon?: string
badge?: string
blog?: string
docs?: string
description?: string
github?: string
hackernews?: string
product_hunt?: string
thumb?: string
url?: string
video?: string
twitter_spaces?: string
className?: string
hideInBlog?: boolean
bg_layers?: {
img?: string
mobileImg?: string
imgLight?: string
mobileImgLight?: string
className?: string
}[]
steps?: StepProps[] | []
links?: StepLink[]
}
export interface WeekDayProps {
id: string
title: string
date: string
d: number
dd: string
published_at: string
shipped: boolean // show card in layout
isToday?: boolean // current active day
hasCountdown?: boolean // use countdown only on "tomorrow"
description: string | ReactNode
links?: StepLink[] // types = 'productHunt' | 'video' | 'docs' | 'xSpace' | 'blog' | 'podcast'
videoId?: string // youtube id
videoThumbnail?: string
blog: string
steps: StepProps[] | []
}
export const endOfLW13Hackathon = '2025-04-04T23:59:59.999-08:00'
const days: (isDark?: boolean) => WeekDayProps[] = (isDark = true) => [
{
id: 'day-1',
d: 1,
dd: 'Mon',
shipped: false,
isToday: false,
hasCountdown: true,
blog: '',
date: '31 March',
published_at: '2025-03-31T08:00:00.000-07:00',
title: '',
description: null,
links: [
// {
// type: 'video',
// href: '',
// },
// {
// type: 'xSpace',
// href: 'https://twitter.com/i/spaces/1yoJMyjzVWRJQ',
// },
],
steps: [],
},
{
id: 'day-2',
d: 2,
dd: 'Tue',
shipped: false,
isToday: false,
hasCountdown: false,
blog: '',
date: '01 April',
published_at: '2025-04-01T08:00:00.000-07:00',
title: '',
description: null,
links: [
// {
// type: 'video',
// href: '',
// },
// {
// type: 'xSpace',
// href: 'https://twitter.com/i/spaces/1yoJMyjzVWRJQ',
// },
],
steps: [],
},
{
id: 'day-3',
d: 3,
dd: 'Wed',
shipped: false,
isToday: false,
hasCountdown: false,
blog: '',
date: '02 April',
published_at: '2025-04-02T08:00:00.000-07:00',
title: '',
description: null,
links: [
// {
// type: 'video',
// href: '',
// },
// {
// type: 'xSpace',
// href: 'https://twitter.com/i/spaces/1yoJMyjzVWRJQ',
// },
],
steps: [],
},
{
id: 'day-4',
d: 4,
dd: 'Thu',
shipped: false,
isToday: false,
hasCountdown: false,
blog: '',
date: '03 April',
published_at: '2025-04-03T08:00:00.000-07:00',
title: '',
description: null,
links: [
// {
// type: 'video',
// href: '',
// },
// {
// type: 'xSpace',
// href: 'https://twitter.com/i/spaces/1yoJMyjzVWRJQ',
// },
],
steps: [],
},
{
id: 'day-5',
d: 5,
dd: 'Fri',
shipped: false,
isToday: false,
hasCountdown: false,
blog: '',
date: '04 April',
published_at: '2025-04-04T08:00:00.000-07:00',
title: '',
description: null,
links: [
// {
// type: 'video',
// href: '',
// },
// {
// type: 'xSpace',
// href: 'https://twitter.com/i/spaces/1yoJMyjzVWRJQ',
// },
],
steps: [],
},
]
export default days

View File

@@ -0,0 +1,123 @@
import { cn } from 'ui'
import { useThreeJS } from './helpers'
import { useCallback, useEffect, useRef, useState } from 'react'
import SceneRenderer from './utils/SceneRenderer'
import TicketScene from './scenes/TicketScene'
import useConfData from './hooks/use-conf-data'
import HUDScene from './scenes/HUDScene'
interface TicketCanvasProps {
className?: string
onUpgradeToSecret?: () => void
narrow: boolean
}
const TicketCanvas = ({ className, onUpgradeToSecret, narrow }: TicketCanvasProps) => {
const sceneRef = useRef<TicketScene | null>(null)
const initQueue = useRef<{ init: Promise<void>; renderer: SceneRenderer }[]>([])
const onUpgradeToSecretRef = useRef(onUpgradeToSecret)
const [state, dispatch] = useConfData()
const userData = state.userTicketData
const initialSceneDataRef = useRef({
visible: state.ticketVisibility,
secret: state.userTicketData.secret,
platinum: state.userTicketData.platinum,
narrow: narrow,
user: {
id: state.userTicketData.id,
name: state.userTicketData.name ?? state.userTicketData.username,
ticketNumber: state.userTicketData.ticket_number,
},
})
const setup = useCallback(
(container: HTMLElement) => {
const uuid = Math.random().toString(36).substring(7)
const sceneRenderer = new SceneRenderer(container, initQueue.current, uuid)
const initPromise = sceneRenderer.init(async () => {
dispatch({ type: 'TICKET_LOADING_START' })
const scene = new TicketScene({
defaultVisible: initialSceneDataRef.current.visible,
defaultSecret: initialSceneDataRef.current.secret,
defaultPlatinum: initialSceneDataRef.current.platinum,
narrow: initialSceneDataRef.current.narrow,
user: initialSceneDataRef.current.user,
onSeatChartButtonClicked: () => {
scene.showBackSide()
scene.upgradeToSecret()
onUpgradeToSecretRef.current?.()
},
onGoBackButtonClicked: () => {
scene.showFrontSide()
},
})
await sceneRenderer.activateScene(scene, true)
sceneRef.current = scene
dispatch({ type: 'TICKET_LOADING_SUCCESS' })
})
initQueue.current.push({ init: initPromise, renderer: sceneRenderer })
return sceneRenderer
},
[dispatch]
)
useEffect(() => {
async function updateTicket() {
if (sceneRef.current) {
sceneRef.current.setVisible(state.ticketVisibility)
sceneRef.current.setTicketNumber(state.userTicketData.ticket_number ?? 0)
sceneRef.current.setUserName(
state.userTicketData.name ?? state.userTicketData.username ?? ''
)
if (state.userTicketData.secret) await sceneRef.current.upgradeToSecret()
if (state.userTicketData.platinum) await sceneRef.current.upgradeToPlatinum()
sceneRef.current.reloadTextures()
}
}
if (sceneRef.current) {
void updateTicket()
}
}, [
narrow,
state.ticketVisibility,
state.userTicketData.name,
state.userTicketData.platinum,
state.userTicketData.secret,
state.userTicketData.ticket_number,
state.userTicketData.username,
])
useEffect(() => {
onUpgradeToSecretRef.current = onUpgradeToSecret
}, [onUpgradeToSecret])
const { containerRef } = useThreeJS(setup)
return (
<div
className={cn(
'absolute inset-0 flex justify-center items-center overflow-hidden w-full h-full z-0',
className
)}
>
<div
ref={containerRef}
className="w-full h-full"
onClick={(e) => {
sceneRef.current?.click(e.nativeEvent)
}}
/>
</div>
)
}
export default TicketCanvas

View File

@@ -0,0 +1,29 @@
import { ReactNode } from 'react'
import useConfData from './hooks/use-conf-data'
export const TicketClaim = ({ children, narrow }: { children: ReactNode; narrow?: boolean }) => {
return <div className={'flex '}>{children}</div>
}
export const TicketClaimContent = ({ children }: { children?: ReactNode }) => {
return <div className="grid gap-6 content-center">{children}</div>
}
export const TicketClaimMessage = () => {
const [state] = useConfData()
return (
<div className="grid justify-center gap-3 md:px-16">
<div className='md:text-center md:justify-center text-foreground-lighter md:text-sm font-["Departure_Mono"] leading-normal [text-shadow:_0px_0px_4px_rgb(255_255_255_/_0.25)] text-sm text-balance max-w-[310px] md:max-w-[400px] uppercase'>
Share your ticket for a chance to win swag
</div>
</div>
)
}
export const TicketClaimButtons = ({ children }: { children?: ReactNode }) => {
return (
<div className="inline-flex md:justify-center md:items-center gap-2.5 flex-wrap">
{children}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { useState, useRef } from 'react'
import { LW14_URL } from '~/lib/constants'
import { Check, Copy } from 'lucide-react'
import useConfData from './hooks/use-conf-data'
import { cn } from 'ui'
export default function TicketCopy({ className }: { className?: string }) {
const [state] = useConfData()
const userData = state.userTicketData
const { username } = userData
const [copied, setCopied] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const displayUrl = `.../launch-week/tickets/${username}`
const link = `${LW14_URL}/tickets/${username}`
return (
<button
type="button"
name="Copy"
ref={buttonRef}
onClick={() => {
navigator.clipboard.writeText(link).then(() => {
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 2000)
})
}}
className={cn(
'font-mono w-full px-2 lg:px-3.5 !pr-1 py-1 rounded-md bg-alternative-200 border flex gap-2 relative text-foreground-light hover:text-foreground text-xs pointer-events-auto justify-between items-center hover:border-stronger transition-all',
className
)}
>
<span className="truncate">{displayUrl}</span>
<div className="w-6 min-w-6 h-6 flex items-center justify-center flex-shrink-0 border border-strong rounded bg-muted hover:bg-selection hover:border-stronger">
{copied ? <Check size={14} strokeWidth={3} /> : <Copy size={14} strokeWidth={1.5} />}
</div>
</button>
)
}

View File

@@ -0,0 +1,23 @@
import { ReactNode } from 'react'
import { cn } from 'ui'
import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn'
export const TicketLayout = ({ children }: { children: ReactNode }) => {
return (
<SectionContainerWithCn height="narrow" className="font-mono z-20 relative">
{children}
</SectionContainerWithCn>
)
}
export const TicketLayoutCanvas = ({ children, narrow }: { children: ReactNode; narrow: true }) => {
return (
<div
className={cn('relative w-full h-[650px] lg:h-auto lg:aspect-[1.5841584158]', {
['h-[530px] lg:aspect-[2.3] xl:aspect-[2.9473684211]']: narrow,
})}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,111 @@
import { useBreakpoint } from 'common'
import dayjs from 'dayjs'
import { Check } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Button } from 'ui'
import useConfData from './hooks/use-conf-data'
import {
LW14_TWEET_TEXT,
LW14_TWEET_TEXT_PLATINUM,
LW14_TWEET_TEXT_SECRET,
LW14_URL,
} from '~/lib/constants'
import supabase from './supabase'
export default function TicketShare() {
const { resolvedTheme } = useTheme()
const [state] = useConfData()
const userData = state.userTicketData
const { platinum, username, metadata, secret: hasSecretTicket } = userData
const [_imgReady, setImgReady] = useState(false)
const [_loading, setLoading] = useState(false)
const isLessThanMd = useBreakpoint()
const downloadLink = useRef<HTMLAnchorElement>()
const link = `${LW14_URL}/tickets/${username}&t=${dayjs(new Date()).format('DHHmmss')}`
const permalink = encodeURIComponent(link)
const text = hasSecretTicket
? LW14_TWEET_TEXT_SECRET
: platinum
? LW14_TWEET_TEXT_PLATINUM
: LW14_TWEET_TEXT
const encodedText = encodeURIComponent(text)
const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink}&text=${encodedText}`
const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink}&text=${encodedText}`
const downloadUrl = `/api-v2/ticket-og?username=${encodeURIComponent(username ?? '')}`
const TICKETS_TABLE = 'tickets'
useEffect(() => {
setImgReady(false)
const img = new Image()
img.src = downloadUrl
img.onload = () => {
setImgReady(true)
setLoading(false)
if (downloadLink.current) {
downloadLink.current.click()
downloadLink.current = undefined
}
}
}, [downloadUrl])
const handleShare = async (social: 'twitter' | 'linkedin') => {
setTimeout(async () => {
if (social === 'twitter') {
await supabase
.from(TICKETS_TABLE)
.update({
shared_on_twitter: 'now',
metadata: { ...metadata, theme: resolvedTheme, hasSharedSecret: hasSecretTicket },
})
.eq('launch_week', 'lw14')
.eq('username', username)
} else if (social === 'linkedin') {
await supabase
.from(TICKETS_TABLE)
.update({
shared_on_linkedin: 'now',
metadata: { ...metadata, theme: resolvedTheme, hasSharedSecret: hasSecretTicket },
})
.eq('launch_week', 'lw14')
.eq('username', username)
}
if (userData.shared_on_linkedin && userData.shared_on_twitter) {
await fetch(`/api-v2/ticket-og?username=${username}&platinum=true`)
}
})
}
return (
<div className='flex flex-row flex-wrap justify-stretch w-full gap-2 pointer-events-auto font-["Departure_Mono"]'>
<Button
onClick={() => handleShare('twitter')}
type={userData.shared_on_twitter ? 'secondary' : 'default'}
icon={userData.shared_on_twitter && <Check strokeWidth={2} />}
size={isLessThanMd ? 'tiny' : 'small'}
className="px-2 lg:px-3.5 h-[28px] lg:h-[34px] uppercase flex-1 w-full"
asChild
>
<Link href={tweetUrl} target="_blank">
{userData.shared_on_twitter ? 'Shared on Twitter' : 'Share on Twitter'}
</Link>
</Button>
<Button
onClick={() => handleShare('linkedin')}
type={userData.shared_on_linkedin ? 'secondary' : 'default'}
icon={userData.shared_on_linkedin && <Check strokeWidth={2} />}
size={isLessThanMd ? 'tiny' : 'small'}
className="px-2 lg:px-3.5 h-[28px] lg:h-[34px] uppercase flex-1 w-full"
asChild
>
<Link href={linkedInUrl} target="_blank">
{userData.shared_on_linkedin ? 'Shared on Linkedin' : 'Share on Linkedin'}
</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { ReactNode } from 'react'
import { cn } from 'ui'
import useLw14ConfData from './hooks/use-conf-data'
export const TicketShareLayout = ({
children,
narrow,
}: {
children?: ReactNode
narrow?: boolean
}) => {
const [{ userTicketData }] = useLw14ConfData()
const userName = userTicketData.name ?? userTicketData.username
let userWelcomeText = userName ? <span>, {userName}!</span> : '!'
return (
<div
className={cn(
'absolute lg:relative lg:flex lg:flex-col lg:max-w-[450px] xl:max-w-[500px] lg:h-full left-0 right-0 grid justify-center gap-2 md:px-5 lg:px-0',
narrow
? 'top-[280px] md:top-[360px] lg:top-0 lg:left-0 lg:right-auto lg:justify-center'
: 'top-[250px] xs:top-[290px] md:top-auto md:bottom-10 xl:bottom-16 2xl:bottom-20'
)}
>
{narrow && (
<>
{/* <Image
src={logo}
alt="LW logo"
className="size-12 hidden lg:block"
width="48"
height="48"
/> */}
<div className='uppercase text-3xl lg:text-4xl py-4 text-left font-["Departure_Mono"]'>
Good to see you{userWelcomeText}
</div>
</>
)}
<div
className={cn(
'text-xs pb-2 font-["Departure_Mono"] uppercase text-foreground-lighter',
narrow ? 'text-left' : 'text-center'
)}
>
Share your ticket for a chance to win swag
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import Image from 'next/image'
import React from 'react'
import tunnelImage from './assets/tunnel-bg.png'
interface TunnelProps {
width?: number
height?: number
className?: string
}
export const Tunnel: React.FC<TunnelProps> = ({ className = '' }) => {
return <Image src={tunnelImage} alt="" width="1120" height="380" className="w-full h-auto" />
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,89 @@
export const CRTShader = {
uniforms: {
tDiffuse: { value: null },
time: { value: 0 },
scanlineIntensity: { value: 0.6 },
scanlineCount: { value: 360 },
vignetteIntensity: { value: 0.5 },
noiseIntensity: { value: 0.01 },
flickerIntensity: { value: 0.01 },
rgbShiftAmount: { value: 0.0 },
// Add intensity control
intensity: { value: 1.0 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float time;
uniform float scanlineIntensity;
uniform float scanlineCount;
uniform float vignetteIntensity;
uniform float noiseIntensity;
uniform float flickerIntensity;
uniform float rgbShiftAmount;
uniform float intensity;
varying vec2 vUv;
// Random function
// https://stackoverflow.com/a/10625698
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
void main() {
// Get original color
vec4 originalColor = texture2D(tDiffuse, vUv);
// Skip effects if intensity is zero
if (intensity <= 0.0) {
gl_FragColor = originalColor;
return;
}
// Scale effect parameters by intensity
float activeRGBShift = rgbShiftAmount * intensity;
float activeScanlineIntensity = scanlineIntensity * intensity;
float activeVignetteIntensity = vignetteIntensity * intensity;
float activeNoiseIntensity = noiseIntensity * intensity;
float activeFlickerIntensity = flickerIntensity * intensity;
// RGB shift effect - more subtle to preserve detail
vec2 shiftR = vec2(activeRGBShift, 0.0);
vec2 shiftG = vec2(0.0, activeRGBShift);
float r = texture2D(tDiffuse, vUv + shiftR).r;
float g = texture2D(tDiffuse, vUv + shiftG).g;
float b = texture2D(tDiffuse, vUv).b;
vec4 shiftedColor = vec4(r, g, b, originalColor.a);
// Scanline effect - thicker and more pronounced
float scanlineFreq = scanlineCount * 0.7; // Reduce frequency for thicker lines
float scanline = sin(vUv.y * scanlineFreq * 3.14159) * 0.5 + 0.5;
scanline = pow(scanline, 0.7) * activeScanlineIntensity * 1.5; // Increase intensity and make lines thicker with lower power
// Apply scanline as a multiplicative overlay with stronger effect
vec4 scanlineColor = mix(shiftedColor, shiftedColor * (1.0 - scanline), activeScanlineIntensity * 1.2);
// Vignette effect
vec2 center = vec2(0.5, 0.5);
float dist = distance(vUv, center);
float vignette = smoothstep(0.3, 0.85, dist) * activeVignetteIntensity;
// Apply vignette as the final overlay effect
vec4 finalColor = scanlineColor * (1.0 - vignette * 0.7);
// Blend between original and effect based on intensity
vec4 final = mix(originalColor, finalColor, intensity);
final.a = originalColor.a;
gl_FragColor = final;
}
`,
}

View File

@@ -0,0 +1,112 @@
import {
DataTexture,
FloatType,
MathUtils,
RedFormat,
ShaderMaterial,
UniformsUtils,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
import { Pass, FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
import { DigitalGlitch } from 'three/examples/jsm/shaders/DigitalGlitch'
class GlitchPass extends Pass {
uniforms: typeof DigitalGlitch.uniforms
heightMap: DataTexture
material: ShaderMaterial
fsQuad: FullScreenQuad
goWild: boolean
curF: number
randX: number
intensity: number
constructor(dt_size = 64) {
super()
const shader = DigitalGlitch
this.uniforms = UniformsUtils.clone(shader.uniforms)
this.heightMap = this.generateHeightmap(dt_size)
this.uniforms['tDisp'].value = this.heightMap
this.material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader,
})
this.fsQuad = new FullScreenQuad(this.material)
this.goWild = false
this.curF = 0
this.randX = 0
this.generateTrigger()
this.intensity = 0
}
render(renderer: WebGLRenderer, writeBuffer: WebGLRenderTarget, readBuffer: WebGLRenderTarget) {
this.uniforms['tDiffuse'].value = readBuffer.texture
this.uniforms['seed'].value = Math.random() //default seeding
this.uniforms['byp'].value = 0
if (this.curF % this.randX < this.randX / 10) {
this.uniforms['amount'].value = 0 * this.intensity
this.uniforms['angle'].value = Math.PI
this.uniforms['distortion_x'].value = MathUtils.randFloat(0, 0.05) * this.intensity
this.uniforms['distortion_y'].value = MathUtils.randFloat(0, 0.05) * this.intensity
this.uniforms['seed_x'].value = MathUtils.randFloat(-0.05, 0.05) * this.intensity
this.uniforms['seed_y'].value = MathUtils.randFloat(-0.05, 0.05) * this.intensity
this.curF = 0
}
this.curF++
if (this.renderToScreen) {
renderer.setRenderTarget(null)
this.fsQuad.render(renderer)
} else {
renderer.setRenderTarget(writeBuffer)
if (this.clear) renderer.clear()
this.fsQuad.render(renderer)
}
}
generateTrigger() {
this.randX = MathUtils.randInt(200, 240)
}
generateHeightmap(dt_size: number) {
const data_arr = new Float32Array(dt_size * dt_size)
const length = dt_size * dt_size
for (let i = 0; i < length; i++) {
const val = MathUtils.randFloat(0, 1)
data_arr[i] = val
}
const texture = new DataTexture(data_arr, dt_size, dt_size, RedFormat, FloatType)
texture.needsUpdate = true
return texture
}
setIntensity(amount: number) {
this.intensity = amount < 0.01 ? 0 : amount / 4
}
enable(value: boolean) {
this.uniforms['byp'].value = value ? 0 : 1
}
dispose() {
this.material.dispose()
this.heightMap.dispose()
this.fsQuad.dispose()
}
}
export { GlitchPass }

View File

@@ -0,0 +1,238 @@
export const shared = `
vec3 mod289(vec3 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 mod289(vec4 x) {
return x - floor(x * (1.0 / 289.0)) * 289.0;
}
vec4 permute(vec4 x)
{
return mod289(((x*34.0)+1.0)*x);
}
vec4 taylorInvSqrt(vec4 r)
{
return 1.79284291400159 - 0.85373472095314 * r;
}
`
export const simplexNoise3d = `
//
// Description : Array and textureless GLSL 2D/3D/4D simplex
// noise functions.
// Author : Ian McEwan, Ashima Arts.
// Maintainer : ijm
// Lastmod : 20110822 (ijm)
// License : Copyright (C) 2011 Ashima Arts. All rights reserved.
// Distributed under the MIT License. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
float snoise3D(vec3 v)
{
const vec2 C = vec2(1.0/6.0, 1.0/3.0) ;
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
// First corner
vec3 i = floor(v + dot(v, C.yyy) );
vec3 x0 = v - i + dot(i, C.xxx) ;
// Other corners
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min( g.xyz, l.zxy );
vec3 i2 = max( g.xyz, l.zxy );
// x0 = x0 - 0.0 + 0.0 * C.xxx;
// x1 = x0 - i1 + 1.0 * C.xxx;
// x2 = x0 - i2 + 2.0 * C.xxx;
// x3 = x0 - 1.0 + 3.0 * C.xxx;
vec3 x1 = x0 - i1 + C.xxx;
vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y
vec3 x3 = x0 - D.yyy; // -1.0+3.0*C.x = -0.5 = -D.y
// Permutations
i = mod289(i);
vec4 p = permute( permute( permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
// Gradients: 7x7 points over a square, mapped onto an octahedron.
// The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
float n_ = 0.142857142857; // 1.0/7.0
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7)
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_ ); // mod(j,N)
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4( x.xy, y.xy );
vec4 b1 = vec4( x.zw, y.zw );
//vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;
//vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy ;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww ;
vec3 p0 = vec3(a0.xy,h.x);
vec3 p1 = vec3(a0.zw,h.y);
vec3 p2 = vec3(a1.xy,h.z);
vec3 p3 = vec3(a1.zw,h.w);
//Normalise gradients
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
p0 *= norm.x;
p1 *= norm.y;
p2 *= norm.z;
p3 *= norm.w;
// Mix final noise value
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),
dot(p2,x2), dot(p3,x3) ) );
}
#pragma glslify: export(snoise)
`
export const periodicNoise3d = `
//
// GLSL textureless classic 3D noise "cnoise",
// with an RSL-style periodic variant "pnoise".
// Author: Stefan Gustavson (stefan.gustavson@liu.se)
// Version: 2011-10-11
//
// Many thanks to Ian McEwan of Ashima Arts for the
// ideas for permutation and gradient selection.
//
// Copyright (c) 2011 Stefan Gustavson. All rights reserved.
// Distributed under the MIT license. See LICENSE file.
// https://github.com/ashima/webgl-noise
//
vec3 fade(vec3 t) {
return t*t*t*(t*(t*6.0-15.0)+10.0);
}
// Classic Perlin noise, periodic variant
float pnoise3D(vec3 P, vec3 rep)
{
vec3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
vec3 Pi1 = mod(Pi0 + vec3(1.0), rep); // Integer part + 1, mod period
Pi0 = mod289(Pi0);
Pi1 = mod289(Pi1);
vec3 Pf0 = fract(P); // Fractional part for interpolation
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
vec4 iy = vec4(Pi0.yy, Pi1.yy);
vec4 iz0 = Pi0.zzzz;
vec4 iz1 = Pi1.zzzz;
vec4 ixy = permute(permute(ix) + iy);
vec4 ixy0 = permute(ixy + iz0);
vec4 ixy1 = permute(ixy + iz1);
vec4 gx0 = ixy0 * (1.0 / 7.0);
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
gx0 = fract(gx0);
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
vec4 sz0 = step(gz0, vec4(0.0));
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
vec4 gx1 = ixy1 * (1.0 / 7.0);
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
gx1 = fract(gx1);
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
vec4 sz1 = step(gz1, vec4(0.0));
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
g000 *= norm0.x;
g010 *= norm0.y;
g100 *= norm0.z;
g110 *= norm0.w;
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
g001 *= norm1.x;
g011 *= norm1.y;
g101 *= norm1.z;
g111 *= norm1.w;
float n000 = dot(g000, Pf0);
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
float n111 = dot(g111, Pf1);
vec3 fade_xyz = fade(Pf0);
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
return 2.2 * n_xyz;
}
#pragma glslify: export(pnoise)
`
export const blendSoftLight = `
// The MIT License (MIT) Copyright (c) 2015 Matt DesLauriers
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
vec3 blendSoftLight(vec3 base, vec3 blend) {
return mix(
sqrt(base) * (2.0 * blend - 1.0) + 2.0 * base * (1.0 - blend),
2.0 * base * blend + base * base * (1.0 - 2.0 * blend),
step(base, vec3(0.5))
);
}
`
export const luma = `
// This software is released under the MIT license:
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
float luma(vec3 color) {
return dot(color, vec3(0.299, 0.587, 0.114));
}
float luma(vec4 color) {
return dot(color.rgb, vec3(0.299, 0.587, 0.114));
}
`

View File

@@ -0,0 +1,132 @@
import { blendSoftLight, luma, periodicNoise3d, shared, simplexNoise3d } from './helpers'
export const HUDShader = {
vertexShader: `
precision mediump float;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec2 resolution;
uniform float vignetteRadius;
uniform float vignetteSmoothness;
uniform float time;
varying vec2 vUv;
varying vec2 screenPosition;
${shared}
${periodicNoise3d}
${simplexNoise3d}
${blendSoftLight}
${luma}
// grain function
// The MIT License (MIT) Copyright (c) 2015 Matt DesLauriers
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
float grain(vec2 texCoord, vec2 resolution, float frame, float multiplier) {
vec2 mult = texCoord * resolution;
float offset = snoise3D(vec3(mult / multiplier, frame));
float n1 = pnoise3D(vec3(mult, offset), vec3(1.0/texCoord * resolution, 1.0));
return n1 / 2.0 + 0.5;
}
float grain(vec2 texCoord, vec2 resolution, float frame) {
return grain(texCoord, resolution, frame, 2.5);
}
float grain(vec2 texCoord, vec2 resolution) {
return grain(texCoord, resolution, 0.0);
}
// Vignette function
// MIT License
//
// Copyright (c) 2017 Tyler Lindberg
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
float vignette(vec2 uv, float radius, float smoothness) {
float diff = radius - distance(uv, vec2(0.5, 0.5));
return smoothstep(-smoothness, smoothness, diff);
}
void main() {
// Sample the diffuse texture to get base color with alpha
vec4 blackBase = vec4(0.0, 0.0, 0.0, 1.0);
float v = vignette(vUv, vignetteRadius, vignetteSmoothness);
float invertedVignette = 1.0 - v;
float minAlpha = 0.3;
blackBase.a *= mix(minAlpha, 1.0, invertedVignette);
vec4 textureColor = texture2D(tDiffuse, vUv);
vec4 stackedColor = vec4(
mix(blackBase.rgb, textureColor.rgb, textureColor.a),
mix(blackBase.a, max(blackBase.a, textureColor.a), 0.8)
);
float grainSize = 3.0 + 4.0 * sin(time);
vec3 g = vec3(grain(vUv, resolution / grainSize, time));
vec3 noiseColor = blendSoftLight(vec3(0.2,0.2,0.2), g);
float grainStrength = 0.02;
stackedColor = vec4(
mix(stackedColor.rgb, noiseColor, grainStrength),
stackedColor.a
);
gl_FragColor = stackedColor;
}
`,
uniforms: {
tDiffuse: { value: null as any },
time: { value: 0.0 },
resolution: { value: [70.0, 100.0] }, // Adjusted for Lygia noise scale
vignetteSmoothness: { value: 0 },
vignetteRadius: { value: 1 },
},
}

View File

@@ -0,0 +1,437 @@
/**
* ISC License
*
* Copyright 2021 mbalex99
*
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted,
* provided that the above copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE
* INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
* FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
* ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
import {
AdditiveBlending,
Color,
LinearFilter,
MeshBasicMaterial,
RGBAFormat,
ShaderMaterial,
Texture,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
import { Pass } from 'three/examples/jsm/postprocessing/Pass'
// typescript definitions doesn't have FullScreenQuad
//@ts-ignore
import { FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass'
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader.js'
import { LuminosityHighPassShader } from 'three/examples/jsm/shaders/LuminosityHighPassShader.js'
/**
* Thanks to https://github.com/mrdoob/three.js/issues/14104#issuecomment-429664412 for this fragmentShaderfix
*
* UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a
* mip map chain of bloom textures and blurs them with different radii. Because
* of the weighted combination of mips, and because larger blurs are done on
* higher mips, this effect provides good quality and performance.
*
* Reference:
* - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/
*/
class TransparentBloomPass extends Pass {
strength: number
radius: number
threshold: number
resolution: Vector2
clearColor: Color
renderTargetsHorizontal: any[]
renderTargetsVertical: any[]
nMips: number
renderTargetBright: WebGLRenderTarget
highPassUniforms: any
materialHighPassFilter: ShaderMaterial
separableBlurMaterials: any[]
compositeMaterial: ShaderMaterial
bloomTintColors: Vector3[]
copyUniforms: any
materialCopy: ShaderMaterial
_oldClearColor: Color
oldClearAlpha: number
basic: MeshBasicMaterial
fsQuad: FullScreenQuad
static BlurDirectionX: any
static BlurDirectionY: any
constructor(resolution: Vector2, strength: number, radius: number, threshold: number) {
super()
this.strength = strength !== undefined ? strength : 1
this.radius = radius
this.threshold = threshold
this.resolution =
resolution !== undefined ? new Vector2(resolution.x, resolution.y) : new Vector2(256, 256)
// create color only once here, reuse it later inside the render function
this.clearColor = new Color(0, 0, 0)
// render targets
const pars = {
minFilter: LinearFilter,
magFilter: LinearFilter,
format: RGBAFormat,
}
this.renderTargetsHorizontal = []
this.renderTargetsVertical = []
this.nMips = 5
let resx = Math.round(this.resolution.x / 2)
let resy = Math.round(this.resolution.y / 2)
this.renderTargetBright = new WebGLRenderTarget(resx, resy, pars)
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright'
this.renderTargetBright.texture.generateMipmaps = false
for (let i = 0; i < this.nMips; i++) {
const renderTargetHorizonal = new WebGLRenderTarget(resx, resy, pars)
renderTargetHorizonal.texture.name = 'UnrealBloomPass.h' + i
renderTargetHorizonal.texture.generateMipmaps = false
this.renderTargetsHorizontal.push(renderTargetHorizonal)
const renderTargetVertical = new WebGLRenderTarget(resx, resy, pars)
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i
renderTargetVertical.texture.generateMipmaps = false
this.renderTargetsVertical.push(renderTargetVertical)
resx = Math.round(resx / 2)
resy = Math.round(resy / 2)
}
// luminosity high pass material
if (LuminosityHighPassShader === undefined)
console.error('THREE.UnrealBloomPass relies on LuminosityHighPassShader')
const highPassShader = LuminosityHighPassShader
this.highPassUniforms = UniformsUtils.clone(highPassShader.uniforms)
this.highPassUniforms['luminosityThreshold'].value = threshold
this.highPassUniforms['smoothWidth'].value = 0.01
this.materialHighPassFilter = new ShaderMaterial({
uniforms: this.highPassUniforms,
vertexShader: highPassShader.vertexShader,
fragmentShader: highPassShader.fragmentShader,
defines: {},
})
// Gaussian Blur Materials
this.separableBlurMaterials = []
const kernelSizeArray = [3, 5, 7, 9, 11]
resx = Math.round(this.resolution.x / 2)
resy = Math.round(this.resolution.y / 2)
for (let i = 0; i < this.nMips; i++) {
this.separableBlurMaterials.push(this.getSeperableBlurMaterial(kernelSizeArray[i]))
this.separableBlurMaterials[i].uniforms['texSize'].value = new Vector2(resx, resy)
resx = Math.round(resx / 2)
resy = Math.round(resy / 2)
}
// Composite material
this.compositeMaterial = this.getCompositeMaterial(this.nMips)
this.compositeMaterial.uniforms['blurTexture1'].value = this.renderTargetsVertical[0].texture
this.compositeMaterial.uniforms['blurTexture2'].value = this.renderTargetsVertical[1].texture
this.compositeMaterial.uniforms['blurTexture3'].value = this.renderTargetsVertical[2].texture
this.compositeMaterial.uniforms['blurTexture4'].value = this.renderTargetsVertical[3].texture
this.compositeMaterial.uniforms['blurTexture5'].value = this.renderTargetsVertical[4].texture
this.compositeMaterial.uniforms['bloomStrength'].value = strength
this.compositeMaterial.uniforms['bloomRadius'].value = 0.1
this.compositeMaterial.needsUpdate = true
const bloomFactors = [1.0, 0.8, 0.6, 0.4, 0.2]
this.compositeMaterial.uniforms['bloomFactors'].value = bloomFactors
this.bloomTintColors = [
new Vector3(1, 1, 1),
new Vector3(1, 1, 1),
new Vector3(1, 1, 1),
new Vector3(1, 1, 1),
new Vector3(1, 1, 1),
]
this.compositeMaterial.uniforms['bloomTintColors'].value = this.bloomTintColors
// copy material
if (CopyShader === undefined) {
console.error('THREE.UnrealBloomPass relies on CopyShader')
}
const copyShader = CopyShader
this.copyUniforms = UniformsUtils.clone(copyShader.uniforms)
this.copyUniforms['opacity'].value = 1.0
this.materialCopy = new ShaderMaterial({
uniforms: this.copyUniforms,
vertexShader: copyShader.vertexShader,
fragmentShader: copyShader.fragmentShader,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true,
})
this.enabled = true
this.needsSwap = false
this._oldClearColor = new Color()
this.oldClearAlpha = 1
this.basic = new MeshBasicMaterial()
this.fsQuad = new FullScreenQuad(undefined)
}
dispose() {
for (let i = 0; i < this.renderTargetsHorizontal.length; i++) {
this.renderTargetsHorizontal[i].dispose()
}
for (let i = 0; i < this.renderTargetsVertical.length; i++) {
this.renderTargetsVertical[i].dispose()
}
this.renderTargetBright.dispose()
}
setSize(width: number, height: number) {
let resx = Math.round(width / 2)
let resy = Math.round(height / 2)
this.renderTargetBright.setSize(resx, resy)
for (let i = 0; i < this.nMips; i++) {
this.renderTargetsHorizontal[i].setSize(resx, resy)
this.renderTargetsVertical[i].setSize(resx, resy)
this.separableBlurMaterials[i].uniforms['texSize'].value = new Vector2(resx, resy)
resx = Math.round(resx / 2)
resy = Math.round(resy / 2)
}
}
render(
renderer: WebGLRenderer,
writeBuffer: any,
readBuffer: { texture: Texture },
deltaTime: any,
maskActive: any
) {
renderer.getClearColor(this._oldClearColor)
this.oldClearAlpha = renderer.getClearAlpha()
const oldAutoClear = renderer.autoClear
renderer.autoClear = false
renderer.setClearColor(this.clearColor, 0)
if (maskActive) renderer.state.buffers.stencil.setTest(false)
// Render input to screen
if (this.renderToScreen) {
this.fsQuad.material = this.basic
this.basic.map = readBuffer.texture
renderer.setRenderTarget(null)
renderer.clear()
this.fsQuad.render(renderer)
}
// 1. Extract Bright Areas
this.highPassUniforms['tDiffuse'].value = readBuffer.texture
this.highPassUniforms['luminosityThreshold'].value = this.threshold
this.fsQuad.material = this.materialHighPassFilter
renderer.setRenderTarget(this.renderTargetBright)
renderer.clear()
this.fsQuad.render(renderer)
// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright
for (let i = 0; i < this.nMips; i++) {
this.fsQuad.material = this.separableBlurMaterials[i]
this.separableBlurMaterials[i].uniforms['colorTexture'].value = inputRenderTarget.texture
this.separableBlurMaterials[i].uniforms['direction'].value =
TransparentBloomPass.BlurDirectionX
renderer.setRenderTarget(this.renderTargetsHorizontal[i])
renderer.clear()
this.fsQuad.render(renderer)
this.separableBlurMaterials[i].uniforms['colorTexture'].value =
this.renderTargetsHorizontal[i].texture
this.separableBlurMaterials[i].uniforms['direction'].value =
TransparentBloomPass.BlurDirectionY
renderer.setRenderTarget(this.renderTargetsVertical[i])
renderer.clear()
this.fsQuad.render(renderer)
inputRenderTarget = this.renderTargetsVertical[i]
}
// Composite All the mips
this.fsQuad.material = this.compositeMaterial
this.compositeMaterial.uniforms['bloomStrength'].value = this.strength
this.compositeMaterial.uniforms['bloomRadius'].value = this.radius
this.compositeMaterial.uniforms['bloomTintColors'].value = this.bloomTintColors
renderer.setRenderTarget(this.renderTargetsHorizontal[0])
renderer.clear()
this.fsQuad.render(renderer)
// Blend it additively over the input texture
this.fsQuad.material = this.materialCopy
this.copyUniforms['tDiffuse'].value = this.renderTargetsHorizontal[0].texture
if (maskActive) renderer.state.buffers.stencil.setTest(true)
if (this.renderToScreen) {
renderer.setRenderTarget(null)
this.fsQuad.render(renderer)
} else {
renderer.setRenderTarget(readBuffer as any)
this.fsQuad.render(renderer)
}
// Restore renderer settings
renderer.setClearColor(this._oldClearColor, this.oldClearAlpha)
renderer.autoClear = oldAutoClear
}
getSeperableBlurMaterial(kernelRadius: number) {
return new ShaderMaterial({
defines: {
KERNEL_RADIUS: kernelRadius,
SIGMA: kernelRadius,
},
uniforms: {
colorTexture: { value: null },
texSize: { value: new Vector2(0.5, 0.5) },
direction: { value: new Vector2(0.5, 0.5) },
},
vertexShader: `varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: `#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 texSize;
uniform vec2 direction;
float gaussianPdf(in float x, in float sigma) {
return 0.39894 * exp( -0.5 * x * x/( sigma * sigma))/sigma;
}
void main() {\n\
vec2 invSize = 1.0 / texSize;\
float fSigma = float(SIGMA);\
float weightSum = gaussianPdf(0.0, fSigma);\
float alphaSum = 0.0;\
vec3 diffuseSum = texture2D( colorTexture, vUv).rgb * weightSum;\
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {\
float x = float(i);\
float w = gaussianPdf(x, fSigma);\
vec2 uvOffset = direction * invSize * x;\
vec4 sample1 = texture2D( colorTexture, vUv + uvOffset);\
vec4 sample2 = texture2D( colorTexture, vUv - uvOffset);\
diffuseSum += (sample1.rgb + sample2.rgb) * w;\
alphaSum += (sample1.a + sample2.a) * w;\
weightSum += 2.0 * w;\
}\
gl_FragColor = vec4(diffuseSum/weightSum, alphaSum/weightSum);\n\
}`,
})
}
getCompositeMaterial(nMips: number) {
return new ShaderMaterial({
defines: {
NUM_MIPS: nMips,
},
uniforms: {
blurTexture1: { value: null },
blurTexture2: { value: null },
blurTexture3: { value: null },
blurTexture4: { value: null },
blurTexture5: { value: null },
dirtTexture: { value: null },
bloomStrength: { value: 1.0 },
bloomFactors: { value: null },
bloomTintColors: { value: null },
bloomRadius: { value: 0.0 },
},
vertexShader: `varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: `varying vec2 vUv;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform sampler2D blurTexture3;
uniform sampler2D blurTexture4;
uniform sampler2D blurTexture5;
uniform sampler2D dirtTexture;
uniform float bloomStrength;
uniform float bloomRadius;
uniform float bloomFactors[NUM_MIPS];
uniform vec3 bloomTintColors[NUM_MIPS];
float lerpBloomFactor(const in float factor) {
float mirrorFactor = 1.2 - factor;
return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}`,
})
}
}
TransparentBloomPass.BlurDirectionX = new Vector2(1.0, 0.0)
TransparentBloomPass.BlurDirectionY = new Vector2(0.0, 1.0)
export { TransparentBloomPass as UnrealBloomPass, TransparentBloomPass }

View File

@@ -0,0 +1,88 @@
import { useRef, useEffect } from 'react'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
/**
* Helper to simplifies controlling requestAnimationFrame
* @param callback Animation callback function
* @returns Object with start and stop functions
*/
export const createThreeAnimation = (callback: (time?: number) => void) => {
const requestRef = { current: undefined } as { current: number | undefined }
const previousTimeRef = { current: undefined } as { current: number | undefined }
const animate = (time: number) => {
if (previousTimeRef.current !== undefined) {
callback(time)
}
previousTimeRef.current = time
requestRef.current = requestAnimationFrame(animate)
}
const start = () => {
requestRef.current = requestAnimationFrame(animate)
}
const stop = () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current)
}
}
return { start, stop }
}
/**
* Helper function to load a GLTF model
* @param url URL of the GLTF model to load
* @returns Promise that resolves with the loaded model
*/
export const loadGLTFModel = (url: string) => {
return new Promise<GLTF>((resolve, reject) => {
const loader = new GLTFLoader()
loader.load(
url,
(gltf) => {
resolve(gltf)
},
undefined,
(error) => reject(error)
)
})
}
/**
* Custom hook for Three.js setup and animation
* @param setupCallback Callback function for setting up the scene
* @returns Object with containerRef
*/
export const useThreeJS = (
setupCallback: (container: HTMLElement) => {
cleanup: () => void
animate: (time?: number) => void
}
) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const container = containerRef.current
const renderer = setupCallback(container)
// Set up animation loop
const { start, stop } = createThreeAnimation(renderer.animate.bind(renderer))
start()
// Cleanup on unmount
return () => {
stop()
renderer.cleanup()
}
}, [setupCallback])
return { containerRef }
}
export function colorObjToRgb(color: { rgb: number; alpha: number }) {
return `rgb(${(color.rgb >> 16) & 255} ${(color.rgb >> 8) & 255} ${(color.rgb >> 0) & 255} / ${color.alpha})`
}

View File

@@ -0,0 +1,233 @@
import { RealtimeChannel, Session, SupabaseClient } from '@supabase/supabase-js'
import { useRouter } from 'next/router'
import { createContext, Dispatch, useContext, useEffect, useMemo, useReducer } from 'react'
/**
* This is copy of shared use-conf-data.ts. For laynch week 14 we need different ticket states.
* To not break the existing functionality, we are creating a new context and hook.
*/
export type TicketState = 'registration' | 'ticket-visible' | 'loading' | 'ticket-loading'
export type UserTicketData = {
id?: string
email?: string
username?: string
name?: string
ticket_number?: number
platinum?: boolean
golden?: boolean
referrals?: number
bg_image_id?: number
role?: string
company?: string
location?: string
metadata?: {
role?: string
company?: string
location?: string
hasSecretTicket?: boolean
hasSharedSecret?: boolean
hideAvatar?: boolean
hideMetadata?: boolean
theme?: string
}
shared_on_twitter?: string
shared_on_linkedin?: string
secret?: boolean
}
type LwAction =
| { type: 'USER_TICKET_FETCH_STARTED' }
| { type: 'USER_TICKET_FETCH_SUCCESS'; payload: UserTicketData }
| { type: 'USER_TICKET_FETCH_ERROR'; payload: Error }
| { type: 'USER_TICKET_UPDATED'; payload: UserTicketData }
| { type: 'SESSION_UPDATED'; payload: Session | null }
| { type: 'TICKET_LOADING_START' }
| { type: 'TICKET_LOADING_SUCCESS' }
| { type: 'TICKET_LOADING_ERROR'; payload?: Error }
| { type: 'PARTYMODE_ENABLE'; payload: RealtimeChannel }
| { type: 'PARTYMODE_DISABLE' }
| { type: 'URL_PARAMS_LOADED'; payload: { referal?: string } }
| {
type: 'GAUGES_DATA_FETCHED'
payload: {
payloadSaturation?: number
payloadFill?: number
meetupsAmount?: number
peopleOnline?: number
}
}
// Define state interface
interface LwState {
userTicketData: UserTicketData
ticketState: TicketState
session: Session | null
userTicketDataState: 'unloaded' | 'loading' | 'error' | 'loaded'
userTicketDataError: Error | null
ticketLoadingState: 'unloaded' | 'loading' | 'error' | 'loaded'
ticketVisibility: boolean
claimFormState: 'initial' | 'visible' | 'hidden'
partymodeStatus: 'on' | 'off'
realtimeGaugesChannel: RealtimeChannel | null
referal?: string
gaugesData: {
payloadSaturation: number | null
payloadFill: number | null
meetupsAmount: number | null
peopleOnline: number | null
} | null
urlParamsLoaded: boolean
}
export const lwReducer = (state: LwState, action: LwAction): LwState => {
switch (action.type) {
case 'SESSION_UPDATED':
return {
...state,
session: action.payload,
// Show claim form if session is not available. Form triggers authentication flow.
claimFormState: !action.payload ? 'visible' : 'hidden',
}
case 'USER_TICKET_FETCH_STARTED':
return { ...state, userTicketDataState: 'loading', userTicketDataError: null }
case 'USER_TICKET_FETCH_SUCCESS':
return {
...state,
userTicketData: action.payload,
ticketVisibility: action.payload !== null && state.ticketLoadingState === 'loaded',
}
case 'USER_TICKET_FETCH_ERROR':
return {
...state,
session: null,
ticketState: 'registration',
userTicketDataState: 'error',
userTicketDataError: action.payload,
}
case 'USER_TICKET_UPDATED':
return {
...state,
userTicketData: action.payload,
userTicketDataState: Boolean(action.payload.id) ? 'loaded' : 'unloaded',
userTicketDataError: null,
}
case 'TICKET_LOADING_START':
return {
...state,
ticketLoadingState: 'loading',
ticketVisibility: false,
}
case 'TICKET_LOADING_SUCCESS':
return {
...state,
ticketLoadingState: 'loaded',
ticketVisibility: Boolean(state.userTicketData.id),
}
case 'TICKET_LOADING_ERROR':
return {
...state,
ticketLoadingState: 'error',
ticketVisibility: false,
claimFormState: 'visible',
}
case 'PARTYMODE_ENABLE': {
return {
...state,
realtimeGaugesChannel: action.payload,
partymodeStatus: 'on',
}
}
case 'PARTYMODE_DISABLE': {
return {
...state,
realtimeGaugesChannel: null,
partymodeStatus: 'off',
}
}
case 'GAUGES_DATA_FETCHED': {
const nonNullableKeys = Object.fromEntries(
Object.entries(action.payload).filter(([_, v]) => v !== undefined)
) as typeof action.payload
const newGaugeData = state.gaugesData
? { ...state.gaugesData, ...nonNullableKeys }
: {
payloadSaturation: null,
payloadFill: null,
meetupsAmount: null,
peopleOnline: null,
...nonNullableKeys,
}
return {
...state,
gaugesData: newGaugeData,
}
}
case 'URL_PARAMS_LOADED': {
return { ...state, urlParamsLoaded: true, referal: action.payload.referal }
}
default:
action satisfies never
return state
}
}
export const Lw14ConfDataContext = createContext<[LwState, Dispatch<LwAction>] | null>(null)
function takeFirst(param: string | string[] | undefined): string | undefined {
if (Array.isArray(param)) {
return param[0]
}
return param ?? undefined
}
export const Lw14ConfDataProvider = ({
children,
initState,
}: {
children: React.ReactNode
initState?: Partial<LwState>
}) => {
const { query, isReady } = useRouter()
const providerValue = useReducer(lwReducer, {
userTicketData: {},
ticketState: 'loading',
session: null,
ticketLoadingState: 'unloaded',
ticketVisibility: false,
userTicketDataState: 'unloaded',
userTicketDataError: null,
claimFormState: 'initial',
realtimeGaugesChannel: null,
partymodeStatus: 'off',
gaugesData: null,
urlParamsLoaded: false,
...initState,
})
const [, dispatch] = providerValue
useEffect(() => {
if (isReady) {
dispatch({
type: 'URL_PARAMS_LOADED',
payload: { referal: takeFirst(query.referal) ?? takeFirst(query.username) },
})
}
}, [dispatch, isReady, query.referal, query.username])
return (
<Lw14ConfDataContext.Provider value={providerValue}>{children}</Lw14ConfDataContext.Provider>
)
}
export default function useLw14ConfData() {
const result = useContext(Lw14ConfDataContext)
if (!result) {
throw new Error('useLw14ConfData must be used within a Lw14ConfDataProvider')
}
return result
}

View File

@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from 'react'
import useLw14ConfData from './use-conf-data'
import supabase from '../supabase'
import { REALTIME_CHANNEL_STATES, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'
const LW14_TOPIC = 'lw14'
const GAUGES_UPDATES_EVENT = 'gauges-update'
export const usePartymode = () => {
const [state, dispatch] = useLw14ConfData()
const [shouldInitialize, setShouldInitialize] = useState(
state.partymodeStatus === 'on' && !state.realtimeGaugesChannel
)
const createChannelAndSubscribe = useCallback(() => {
const channel = supabase.channel(LW14_TOPIC, {
config: {
broadcast: {
self: true,
ack: true,
},
},
})
const userStatus = {}
return channel
.on('broadcast', { event: GAUGES_UPDATES_EVENT }, (data) => {
const { payload } = data
dispatch({
type: 'GAUGES_DATA_FETCHED',
payload: {
payloadFill: payload.payload_fill,
payloadSaturation: payload.payload_saturation,
meetupsAmount: payload.meetups_amount,
},
})
})
.on('presence', { event: 'sync' }, () => {
const newState = channel.presenceState()
const uniqueUsers = new Set([
...Object.entries(newState).map(([_, value]) => value[0].presence_ref),
])
dispatch({
type: 'GAUGES_DATA_FETCHED',
payload: { peopleOnline: uniqueUsers.size },
})
})
.subscribe(async (status, error) => {
// console.log('Channel status', status, error)
await channel.track(userStatus)
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
dispatch({ type: 'PARTYMODE_ENABLE', payload: channel })
}
})
}, [dispatch])
const toggle = useCallback(async () => {
if (state.partymodeStatus === 'on') {
await state.realtimeGaugesChannel?.unsubscribe()
dispatch({ type: 'PARTYMODE_DISABLE' })
} else {
createChannelAndSubscribe()
}
}, [createChannelAndSubscribe, dispatch, state.partymodeStatus, state.realtimeGaugesChannel])
const fetchGaugesData = useCallback(async () => {
const [{ data: payloadData, error: payloadError }, { data: meetupsData, error: meetupsError }] =
await Promise.all([
supabase.rpc('get_payload_data_for_lw14'),
supabase.rpc('get_meetups_data_for_lw14'),
])
if (payloadError || meetupsError) {
console.error('Error fetching gauges data', payloadError, meetupsError)
return
}
dispatch({
type: 'GAUGES_DATA_FETCHED',
payload: {
payloadFill: payloadData.payload_fill,
payloadSaturation: payloadData.payload_saturation,
meetupsAmount: meetupsData.meetups_amount,
},
})
}, [dispatch])
useEffect(() => {
if (
state.partymodeStatus === 'on' &&
(!state.realtimeGaugesChannel ||
state.realtimeGaugesChannel.state === REALTIME_CHANNEL_STATES.closed)
) {
const channel = createChannelAndSubscribe()
void fetchGaugesData()
return () => {
if (
state.partymodeStatus === 'off' &&
state.realtimeGaugesChannel?.state === REALTIME_CHANNEL_STATES.joined
)
channel.unsubscribe()
}
}
}, [
createChannelAndSubscribe,
fetchGaugesData,
shouldInitialize,
state.partymodeStatus,
state.realtimeGaugesChannel,
])
return {
toggle,
}
}

View File

@@ -0,0 +1,214 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { REALTIME_CHANNEL_STATES, RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'
import useConfData from './use-conf-data'
import { LW14_URL } from '~/lib/constants'
import supabase from '../supabase'
function subscribeToTicketChanges(
username: string,
onChange: (payload: any) => void
): RealtimeChannel {
return supabase
.channel('changes')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'tickets',
filter: `username=eq.${username}`,
},
onChange
)
.subscribe()
}
interface RegistrationProps {
onRegister?: () => void
onError?: (error: any) => void
}
export const useRegistration = ({ onError, onRegister }: RegistrationProps = {}) => {
// const [realtimeChannel, setRealtimeChannel] = useState<RealtimeChannel | null>(null)
const [
{ userTicketData: userData, session, userTicketDataState, urlParamsLoaded, referal },
dispatch,
] = useConfData()
const sessionUser = session?.user
const callbacksRef = useRef({ onError, onRegister })
// Triggered on session
const fetchOrCreateUser = useCallback(async () => {
if (['loading', 'loaded'].includes(userTicketDataState)) return
if (!urlParamsLoaded) return
if (!sessionUser) {
console.warn('Cannot fetch user without session. Skipping...')
return
}
const username = sessionUser.user_metadata?.user_name as string | undefined
const name = sessionUser.user_metadata.full_name
const email = sessionUser.email
const userId = sessionUser.id
if (!username) {
throw new Error('Username is required')
}
if (!userData.id) {
dispatch({ type: 'USER_TICKET_FETCH_STARTED' })
// console.log('Inserting ticket for user', username, referal)
const { error: ticketInsertError } = await supabase
.from('tickets')
.insert({
user_id: userId,
launch_week: 'lw14',
email,
name,
username,
referred_by: referal ?? null,
})
.select()
.single()
// If error because of duplicate email, ignore and proceed, otherwise sign out.
if (ticketInsertError && ticketInsertError?.code !== '23505') {
dispatch({ type: 'USER_TICKET_FETCH_ERROR', payload: ticketInsertError })
callbacksRef.current.onError?.(ticketInsertError)
return supabase.auth.signOut()
}
const { data, error: ticketsViewError } = await supabase
.from('tickets_view')
.select('*')
.eq('launch_week', 'lw14')
.eq('username', username)
.single()
if (ticketsViewError) {
dispatch({ type: 'USER_TICKET_FETCH_ERROR', payload: ticketsViewError })
callbacksRef.current.onError?.(ticketsViewError)
return
}
dispatch({ type: 'USER_TICKET_FETCH_SUCCESS', payload: data })
await prefetchData(username)
}
callbacksRef.current.onRegister?.()
}, [dispatch, referal, sessionUser, urlParamsLoaded, userData.id, userTicketDataState])
async function prefetchData(username: string) {
// Prefetch GitHub avatar
// new Image().src = `https://github.com/${username}.png`
// Prefetch the twitter share URL to eagerly generate the page
await fetch(`/api-v2/ticket-og?username=${username}`)
}
const handleGithubSignIn = useCallback(async () => {
let redirectTo = `${LW14_URL}`
if (referal) {
redirectTo += `?referal=${referal}`
}
const response = await supabase?.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo,
},
})
if (response) {
if (response.error) {
callbacksRef.current?.onError?.(response.error)
return
}
}
}, [referal])
useEffect(() => {
fetchOrCreateUser()
const username = sessionUser?.user_metadata?.user_name as string | undefined
if (username) {
const channel = subscribeToTicketChanges(username, (payload) => {
const platinum = !!payload.new.shared_on_twitter && !!payload.new.shared_on_linkedin
const secret = !!payload.new.game_won_at
dispatch({
type: 'USER_TICKET_UPDATED',
payload: {
...payload.new,
platinum,
secret,
},
})
})
return () => {
// Cleanup realtime subscription on unmount
channel?.unsubscribe()
}
}
}, [dispatch, fetchOrCreateUser, sessionUser?.user_metadata?.user_name])
useEffect(() => {
supabase.auth.getSession().then(({ data, error }) => {
if (error) console.error('Session error', error)
dispatch({ type: 'SESSION_UPDATED', payload: data.session })
})
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
dispatch({ type: 'SESSION_UPDATED', payload: session })
if (session && window.location.hash.includes('access_token')) {
window.history.replaceState(null, '', window.location.pathname + window.location.search)
}
})
return () => subscription.unsubscribe()
}, [dispatch])
useEffect(() => {
callbacksRef.current = { onError, onRegister }
})
const upgradeTicket = async () => {
if (userData.id) {
if (userData.secret) {
// console.log('User already has a secret ticket')
return
}
const { error } = await supabase
.from('tickets')
.update({ game_won_at: new Date() })
.eq('launch_week', 'lw14')
.eq('username', userData.username)
if (error) {
return console.error('Failed to upgrade user ticket', error)
}
// Trigger og-image ticket generation
await fetch(`/api-v2/ticket-og?username=${userData.username}&secret=true`)
} else {
console.warn('Cannot upgrade ticket without user data')
}
}
return {
signIn: handleGithubSignIn,
upgradeTicket,
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
/* Font face definitions for Launch Week 14 */
@font-face {
font-family: 'Departure Mono';
src: url('/fonts/launchweek/14/DepartureMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Nippo-Variable';
src: url('/fonts/launchweek/14/Nippo-Variable.woff2') format('woff2');
font-weight: 400 700;
font-style: normal;
font-display: swap;
}

View File

@@ -0,0 +1,9 @@
import { SupabaseClient } from '@supabase/supabase-js'
import supabase from '~/lib/supabase'
/**
* Override supabase types similar to previous use-conf-data.ts.
* Current apps/www supabase instance uses old types.
* Since we don't know final db layout let's leave it as any.
*/
export default supabase as SupabaseClient

View File

@@ -0,0 +1,295 @@
import {
ColorManagement,
HalfFloatType,
PerspectiveCamera,
Scene,
SRGBColorSpace,
WebGLRenderer,
WebGLRenderTarget,
} from 'three'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
export interface MousePositionState {
clientX: number
clientY: number
isWithinContainer: boolean
containerX: number
containerY: number
mouseIntensity: number
}
export interface BaseScene {
getId(): string
/**
* Initialize the scene with the given context
*/
setup(context: SceneRenderer): Promise<Scene | void>
/**
* Update the scene for each animation frame
*/
update(context: SceneRenderer, dt?: number): void
/**
* Clean up resources when the scene is destroyed
*/
cleanup(): void
resize(ev: UIEvent): void
/**
* Handle device pixel ratio changes
*/
devicePixelRatioChanged?(newPixelRatio: number, oldPixelRatio: number): void
}
class SceneRenderer {
renderer: WebGLRenderer
composer: EffectComposer
camera: PerspectiveCamera
mainThreeJsScene: Scene | null = null
activeScenes: BaseScene[] = []
cachedContainerBBox: DOMRect
mousePositionState: MousePositionState
mouseIntensityDecay = 0.98
mouseIntensityGainRate = 0.003
currentPixelRatio: number
private _resizeHandler: ((ev: UIEvent) => void) | null = null
private _mouseMoveHandler: ((ev: MouseEvent) => void) | null = null
private _mediaQueryListHandler: ((ev: MediaQueryListEvent) => void) | null = null
private _isDisposed = false
private _isInitialized = false
constructor(
public container: HTMLElement,
private waitFor?: { init: Promise<void>; renderer: SceneRenderer }[],
private uuid?: string
) {
this.renderer = new WebGLRenderer({ antialias: true, alpha: true })
this.composer = new EffectComposer(this.renderer)
this.camera = new PerspectiveCamera(
75,
this.container.clientWidth / this.container.clientHeight,
0.1,
1000
)
this.mousePositionState = {
clientX: 0,
clientY: 0,
isWithinContainer: false,
containerX: 0,
containerY: 0,
mouseIntensity: 0,
}
this.cachedContainerBBox = this.container.getBoundingClientRect()
this.currentPixelRatio = window.devicePixelRatio
}
async init(sceneInitializer: () => Promise<void>) {
// console.log('SCENE RENDERER: Init call', this.waitFor?.length, this.uuid)
await Promise.allSettled(
this.waitFor?.filter((t) => t.renderer !== this).map((t) => t.init) || []
)
if (this._isDisposed) {
// console.log('SCENE RENDERER: Already disposed before sceneInitializer', this.uuid)
return
}
// console.log('SCENE RENDERER: Waited for all pending inits', this.waitFor?.length, this.uuid)
await sceneInitializer()
if (this._isDisposed) {
// console.log('SCENE RENDERER: Already disposed after sceneInitializer', this.uuid)
return
}
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setClearColor(0x000000, 0) // Set transparent background
this.renderer.autoClear = false // Prevent automatic clearing
this.renderer.outputColorSpace = SRGBColorSpace
ColorManagement.enabled = true
// Initialize composer with correct size and pixel ratio
this.composer.setSize(this.container.clientWidth, this.container.clientHeight)
this.composer.setPixelRatio(window.devicePixelRatio)
this.container.appendChild(this.renderer.domElement)
this._resizeHandler = this._resize.bind(this)
window.addEventListener('resize', this._resizeHandler)
this._mouseMoveHandler = this._updateMousePosition.bind(this)
window.addEventListener('mousemove', this._mouseMoveHandler)
// Disable it since it brakes Chrome browser
// // Add device pixel ratio change listener
// this._mediaQueryListHandler = this._handleDevicePixelRatioChange.bind(this)
// window
// .matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
// .addEventListener('change', this._mediaQueryListHandler)
}
async activateScene(scene: BaseScene, main?: boolean) {
if (this._isDisposed) {
// console.log('SCENE RENDERER: Already disposed before activateScene', this.uuid)
return
}
const threeScene = await scene.setup(this)
if (this._isDisposed) {
// console.log('SCENE RENDERER: Already disposed after activateScene', this.uuid)
return
}
if (main && threeScene) {
this.mainThreeJsScene = threeScene
}
const newId = scene.getId()
const existingSceneId = this.activeScenes.findIndex((s) => s.getId() === newId)
if (existingSceneId > -1) {
const sceneToRemove = this.activeScenes[existingSceneId]
this.activeScenes[existingSceneId] = scene
sceneToRemove.cleanup()
} else {
this.activeScenes.push(scene)
}
}
animate(time?: number) {
// Clear the renderer before rendering
this.renderer.clear()
this._decayMouseIntensity()
for (const activeScene of this.activeScenes) {
activeScene.update(this, time)
}
this.composer.render(time)
}
cleanup() {
// console.log('SCENE RENDERER: Cleanup', this.uuid)
this._isDisposed = true
this.composer.dispose()
this.renderer.dispose()
try {
this.container.removeChild(this.renderer.domElement)
} catch (e) {
console.warn(e)
}
if (this._resizeHandler) {
window.removeEventListener('resize', this._resizeHandler)
this._resizeHandler = null
}
if (this._mouseMoveHandler) {
window.removeEventListener('mousemove', this._mouseMoveHandler)
this._mouseMoveHandler = null
}
if (this._mediaQueryListHandler) {
window
.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
.removeEventListener('change', this._mediaQueryListHandler)
this._mediaQueryListHandler = null
}
if (this.waitFor) {
// Mutate array in place instead of replacing reference
this.waitFor.splice(
0,
this.waitFor.length,
...this.waitFor.filter((t) => t.renderer === this)
)
}
}
private _resize(ev: UIEvent) {
this.camera.aspect = this.container.clientWidth / this.container.clientHeight
this.camera.updateProjectionMatrix()
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
this.composer.setSize(this.container.clientWidth, this.container.clientHeight)
this.cachedContainerBBox = this.container.getBoundingClientRect()
for (const activeScene of this.activeScenes) {
activeScene.resize(ev)
}
}
private _handleDevicePixelRatioChange(ev: MediaQueryListEvent) {
const oldPixelRatio = this.currentPixelRatio
const newPixelRatio = window.devicePixelRatio
// Update renderer and composer pixel ratio
this.renderer.setPixelRatio(newPixelRatio)
this.composer.setPixelRatio(newPixelRatio)
// Store the new pixel ratio
this.currentPixelRatio = newPixelRatio
// Notify all active scenes about the pixel ratio change
for (const activeScene of this.activeScenes) {
if (typeof activeScene.devicePixelRatioChanged === 'function') {
activeScene.devicePixelRatioChanged(newPixelRatio, oldPixelRatio)
}
}
// console.log(
// `SCENE RENDERER: Device pixel ratio changed from ${oldPixelRatio} to ${newPixelRatio}`
// )
}
private _decayMouseIntensity() {
if (this.mousePositionState.mouseIntensity > 0) {
this.mousePositionState.mouseIntensity *= this.mouseIntensityDecay
if (this.mousePositionState.mouseIntensity < 0.0000001) {
this.mousePositionState.mouseIntensity = 0
}
}
}
private _updateMousePosition(ev: MouseEvent) {
this.cachedContainerBBox = this.container.getBoundingClientRect()
const dx = ev.clientX - this.mousePositionState.clientX
const dy = ev.clientY - this.mousePositionState.clientY
const distance = Math.sqrt(dx * dx + dy * dy)
// Update intensity based on movement (clamped between 0 and 1)
this.mousePositionState.mouseIntensity = Math.min(
1.0,
Math.max(0, this.mousePositionState.mouseIntensity + distance * this.mouseIntensityGainRate)
)
// Update last position
this.mousePositionState.clientX = ev.clientX
this.mousePositionState.clientY = ev.clientY
const rect = this.cachedContainerBBox
if (!rect) {
return
}
const isWithinContainer =
ev.clientX >= rect.left &&
ev.clientX <= rect.right &&
ev.clientY >= rect.top &&
ev.clientY <= rect.bottom
this.mousePositionState.isWithinContainer = isWithinContainer
this.mousePositionState.containerX = ((ev.clientX - rect.left) / rect.width) * 2 - 1
this.mousePositionState.containerY = -((ev.clientY - rect.top) / rect.height) * 2 + 1
}
}
export default SceneRenderer

View File

@@ -0,0 +1,11 @@
export const themes = () => ({
regular: {
TICKET_FOREGROUND: '#1F1F1F',
},
platinum: {
TICKET_FOREGROUND: '#1F1F1F',
},
secret: {
TICKET_FOREGROUND: '#1F1F1F',
},
})

View File

@@ -7,6 +7,10 @@ interface Props {
id?: string
}
/**
* This component doesn't use cn so parent tailwind classes aren't applied correctly
* At this point it's dangerous to fix it because it could break the layout at many places
*/
const SectionContainer = forwardRef(
({ children, className, id }: Props, ref: Ref<HTMLDivElement>) => (
<div
@@ -22,4 +26,6 @@ const SectionContainer = forwardRef(
)
)
SectionContainer.displayName = 'SectionContainer'
export default SectionContainer

View File

@@ -0,0 +1,45 @@
import { Ref, forwardRef } from 'react'
import { cn } from 'ui'
import { cva, type VariantProps } from 'class-variance-authority'
const sectionContainerVariants = cva('max-w-7xl relative mx-auto px-6', {
variants: {
width: {
normal: 'lg:px-16 xl:px-20',
smallScreenFull: 'max-w-full lg:container px-0',
},
height: {
normal: 'py-16 md:py-24 lg:py-24',
narrow: 'py-6 md:py-8',
none: '',
},
},
defaultVariants: {
width: 'normal',
height: 'normal',
},
})
interface Props extends VariantProps<typeof sectionContainerVariants> {
children: React.ReactNode
className?: string
id?: string
}
/**
* To have tailwind classes applied correctly, use this component instead of SectionContainer
*
* @param width - 'normal' (default) or 'full'
* @param height - 'normal' (default) or 'narrow'
*/
const SectionContainerWithCn = forwardRef(
({ children, className, id, width, height }: Props, ref: Ref<HTMLDivElement>) => (
<div ref={ref} id={id} className={cn(sectionContainerVariants({ width, height }), className)}>
{children}
</div>
)
)
SectionContainerWithCn.displayName = 'SectionContainerWithCn'
export default SectionContainerWithCn

View File

@@ -35,7 +35,7 @@ export const SITE_ORIGIN =
? `https://${process.env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: 'http://localhost:3000'
export const LW_URL = `${SITE_ORIGIN}/launch-week`
export const LW_URL = `${SITE_ORIGIN}/launch-week/13`
export const TWITTER_USER_NAME = 'supabase'
export const LW7_DATE = 'April 10th 2023'
@@ -58,4 +58,17 @@ export const TWEET_TEXT =
'Launch Week 13 is just around the corner at @supabase. \nClaim your ticket and stay tuned for all the announcements! \n#launchweek'
export const TWEET_TEXT_PLATINUM = `Just conquered a platinum @supabase Launch Week 13 ticket. Share twice to get one! \n#launchweek`
export const TWEET_TEXT_SECRET = `Found the secret golden ticket for @supabase's Launch Week 13. \nCan you find it? \n#launchweek`
// todo: update dates
export const LW14_DATE = '31 March — 4 April / 7am PT'
export const LW14_LAUNCH_DATE = '2025-03-31T07:00:00.000-07:00'
export const LW14_LAUNCH_DATE_END = '2025-04-04T23:59:59.000-07:00'
export const LW14_TITLE = 'Launch Week 14'
export const LW14_TWEET_TEXT =
'Launch Week 14 is just around the corner at @supabase. \nClaim your ticket and stay tuned for all the announcements! \n#launchweek'
export const LW14_TWEET_TEXT_PLATINUM =
'Just conquered a platinum @supabase Launch Week 14 ticket. Share twice to get one! \n#launchweek'
export const LW14_TWEET_TEXT_SECRET = `Found the secret golden ticket for @supabase's Launch Week 14. \nCan you find it? \n#launchweek`
export const LW14_URL = `${SITE_ORIGIN}/launch-week`
export const SITE_NAME = 'Supabase'

View File

@@ -33,6 +33,7 @@
"@vercel/og": "^0.6.2",
"ai-commands": "workspace:*",
"animejs": "^3.2.2",
"class-variance-authority": "^0.7.1",
"classnames": "^2.3.1",
"clsx": "^1.2.1",
"cobe": "^0.6.2",

View File

@@ -24,6 +24,7 @@ import MetaFaviconsPagesRouter, {
import { WwwCommandMenu } from '~/components/CommandMenu'
import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION } from '~/lib/constants'
import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks'
import { LW14Announcement } from 'ui-patterns/Banners/LW14Announcement'
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter()
@@ -93,6 +94,7 @@ export default function App({ Component, pageProps }: AppProps) {
>
<TooltipProvider delayDuration={0}>
<CommandProvider>
<LW14Announcement />
<SonnerToaster position="top-right" />
<Component {...pageProps} />
<WwwCommandMenu />

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
// import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import { NextSeo } from 'next-seo'
import { Session } from '@supabase/supabase-js'
import { LW13_DATE, LW13_TITLE, LW_URL, SITE_ORIGIN } from '~/lib/constants'
import supabase from '~/lib/supabase'
import DefaultLayout from '~/components/Layouts/Default'
import { TicketState, ConfDataContext, UserData } from '~/components/LaunchWeek/hooks/use-conf-data'
import SectionContainer from '~/components/Layouts/SectionContainer'
// import { Meetup } from '~/components/LaunchWeek/13/LWMeetups'
import LWStickyNav from '~/components/LaunchWeek/13/Releases/LWStickyNav'
import LWHeader from '~/components/LaunchWeek/13/Releases/LWHeader'
import MainStage from '~/components/LaunchWeek/13/Releases/MainStage'
const BuildStage = dynamic(() => import('~/components/LaunchWeek/13/Releases/BuildStage'))
const CTABanner = dynamic(() => import('~/components/CTABanner'))
const TicketingFlow = dynamic(() => import('~/components/LaunchWeek/13/Ticket/TicketingFlow'))
// const LW13Meetups = dynamic(
// () => import('~/components/LaunchWeek/13/LWMeetups')
// )
export default function LaunchWeekIndex() {
const { query } = useRouter()
const TITLE = `${LW13_TITLE} | ${LW13_DATE}`
const DESCRIPTION = 'Join us for a week of announcing new features, every day at 7 AM PT.'
const OG_IMAGE = `${SITE_ORIGIN}/images/launchweek/12/lw13-og.png?lw=12`
const ticketNumber = query.ticketNumber?.toString()
const [session, setSession] = useState<Session | null>(null)
const [showCustomizationForm, setShowCustomizationForm] = useState<boolean>(false)
const defaultUserData = {
id: query.id?.toString(),
ticket_number: ticketNumber ? parseInt(ticketNumber, 10) : undefined,
name: query.name?.toString(),
username: query.username?.toString(),
platinum: !!query.platinum,
}
const [userData, setUserData] = useState<UserData>(defaultUserData)
const [ticketState, setTicketState] = useState<TicketState>('loading')
useEffect(() => {
if (supabase) {
supabase.auth.getSession().then(({ data: { session } }) => setSession(session))
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}
}, [supabase])
useEffect(() => {
if (session?.user) {
if (userData?.id) {
return setTicketState('ticket')
}
return setTicketState('loading')
}
if (!session) return setTicketState('registration')
}, [session, userData])
return (
<>
<NextSeo
title={TITLE}
description={DESCRIPTION}
openGraph={{
title: TITLE,
description: DESCRIPTION,
url: LW_URL,
images: [
{
url: OG_IMAGE,
},
],
}}
/>
<ConfDataContext.Provider
value={{
supabase,
session,
userData,
setUserData,
ticketState,
setTicketState,
showCustomizationForm,
setShowCustomizationForm,
}}
>
<DefaultLayout>
<LWStickyNav />
<LWHeader />
<MainStage className="relative z-10" />
<BuildStage />
{/* <SectionContainer id="meetups" className="scroll-mt-[60px] lw-nav-anchor">
<LW13Meetups meetups={meetups} />
</SectionContainer> */}
<SectionContainer
className="relative !max-w-none border-t border-muted !py-8 scroll-mt-[60px] lw-nav-anchor overflow-hidden"
id="ticket"
>
<TicketingFlow />
</SectionContainer>
<CTABanner />
</DefaultLayout>
</ConfDataContext.Provider>
</>
)
}
// export const getServerSideProps: GetServerSideProps = async () => {
// const { data: meetups } = await supabase!
// .from('meetups')
// .select('*')
// .eq('launch_week', 'lw13')
// .neq('is_published', false)
// .order('start_at')
// return {
// props: {
// meetups,
// },
// }
// }

View File

@@ -1,38 +1,17 @@
import { useState, useEffect } from 'react'
import dynamic from 'next/dynamic'
// import { GetServerSideProps } from 'next'
import { useRouter } from 'next/router'
import { NextSeo } from 'next-seo'
import { Session } from '@supabase/supabase-js'
import { LW13_DATE, LW13_TITLE, LW_URL, SITE_ORIGIN } from '~/lib/constants'
import supabase from '~/lib/supabase'
import { LW14_DATE, LW14_TITLE, LW14_URL, SITE_ORIGIN } from '~/lib/constants'
import { LwView } from '~/components/LaunchWeek/14/LwView'
import { useRouter } from 'next/router'
import { Lw14ConfDataProvider } from '~/components/LaunchWeek/14/hooks/use-conf-data'
import DefaultLayout from '~/components/Layouts/Default'
import { TicketState, ConfDataContext, UserData } from '~/components/LaunchWeek/hooks/use-conf-data'
import SectionContainer from '~/components/Layouts/SectionContainer'
// import { Meetup } from '~/components/LaunchWeek/13/LWMeetups'
import LWStickyNav from '~/components/LaunchWeek/13/Releases/LWStickyNav'
import LWHeader from '~/components/LaunchWeek/13/Releases/LWHeader'
import MainStage from '~/components/LaunchWeek/13/Releases/MainStage'
const BuildStage = dynamic(() => import('~/components/LaunchWeek/13/Releases/BuildStage'))
const CTABanner = dynamic(() => import('~/components/CTABanner'))
const TicketingFlow = dynamic(() => import('~/components/LaunchWeek/13/Ticket/TicketingFlow'))
// const LW13Meetups = dynamic(
// () => import('~/components/LaunchWeek/13/LWMeetups')
// )
export default function LaunchWeekIndex() {
const { query } = useRouter()
const TITLE = `${LW13_TITLE} | ${LW13_DATE}`
const Lw14Page = () => {
const TITLE = `${LW14_TITLE} | ${LW14_DATE}`
const DESCRIPTION = 'Join us for a week of announcing new features, every day at 7 AM PT.'
const OG_IMAGE = `${SITE_ORIGIN}/images/launchweek/12/lw13-og.png?lw=12`
const OG_IMAGE = `${SITE_ORIGIN}/images/launchweek/14/lw14-og.png?lw=14`
const { query } = useRouter()
const ticketNumber = query.ticketNumber?.toString()
const [session, setSession] = useState<Session | null>(null)
const [showCustomizationForm, setShowCustomizationForm] = useState<boolean>(false)
const defaultUserData = {
id: query.id?.toString(),
ticket_number: ticketNumber ? parseInt(ticketNumber, 10) : undefined,
@@ -41,32 +20,6 @@ export default function LaunchWeekIndex() {
platinum: !!query.platinum,
}
const [userData, setUserData] = useState<UserData>(defaultUserData)
const [ticketState, setTicketState] = useState<TicketState>('loading')
useEffect(() => {
if (supabase) {
supabase.auth.getSession().then(({ data: { session } }) => setSession(session))
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}
}, [supabase])
useEffect(() => {
if (session?.user) {
if (userData?.id) {
return setTicketState('ticket')
}
return setTicketState('loading')
}
if (!session) return setTicketState('registration')
}, [session, userData])
return (
<>
<NextSeo
@@ -75,7 +28,7 @@ export default function LaunchWeekIndex() {
openGraph={{
title: TITLE,
description: DESCRIPTION,
url: LW_URL,
url: LW14_URL,
images: [
{
url: OG_IMAGE,
@@ -83,50 +36,14 @@ export default function LaunchWeekIndex() {
],
}}
/>
<ConfDataContext.Provider
value={{
supabase,
session,
userData,
setUserData,
ticketState,
setTicketState,
showCustomizationForm,
setShowCustomizationForm,
}}
>
<DefaultLayout>
<LWStickyNav />
<LWHeader />
<MainStage className="relative z-10" />
<BuildStage />
{/* <SectionContainer id="meetups" className="scroll-mt-[60px] lw-nav-anchor">
<LW13Meetups meetups={meetups} />
</SectionContainer> */}
<SectionContainer
className="relative !max-w-none border-t border-muted !py-8 scroll-mt-[60px] lw-nav-anchor overflow-hidden"
id="ticket"
>
<TicketingFlow />
</SectionContainer>
<CTABanner />
<Lw14ConfDataProvider initState={{ userTicketData: defaultUserData, partymodeStatus: 'on' }}>
<DefaultLayout className='font-["Departure_Mono"] lg:py-32 border-b pb-0'>
<LwView />
</DefaultLayout>
</ConfDataContext.Provider>
</Lw14ConfDataProvider>
</>
)
}
// export const getServerSideProps: GetServerSideProps = async () => {
// const { data: meetups } = await supabase!
// .from('meetups')
// .select('*')
// .eq('launch_week', 'lw13')
// .neq('is_published', false)
// .order('start_at')
// return {
// props: {
// meetups,
// },
// }
// }
export default Lw14Page

View File

@@ -1,45 +1,27 @@
import { useState } from 'react'
import { GetStaticProps, GetStaticPaths } from 'next'
import dayjs from 'dayjs'
import { NextSeo } from 'next-seo'
import Link from 'next/link'
import dayjs from 'dayjs'
import Error from 'next/error'
import { createClient, Session } from '@supabase/supabase-js'
import { Button } from 'ui'
import { LW_URL, SITE_ORIGIN } from '~/lib/constants'
import supabase from '~/lib/supabase'
import { Database } from '~/lib/database.types'
import { AnimatePresence, m, LazyMotion, domAnimation } from 'framer-motion'
import { DEFAULT_TRANSITION, INITIAL_BOTTOM, getAnimation } from '~/lib/animations'
import { GetStaticPaths, GetStaticProps } from 'next'
import { LW14_DATE, LW14_TITLE, LW14_URL, SITE_ORIGIN } from '~/lib/constants'
import { LwView } from '~/components/LaunchWeek/14/LwView'
import {
Lw14ConfDataProvider,
UserTicketData,
} from '~/components/LaunchWeek/14/hooks/use-conf-data'
import { createClient } from '@supabase/supabase-js'
import DefaultLayout from '~/components/Layouts/Default'
import SectionContainer from '~/components/Layouts/SectionContainer'
import { TicketState, ConfDataContext, UserData } from '~/components/LaunchWeek/hooks/use-conf-data'
import CanvasSingleMode from '~/components/LaunchWeek/13/Multiplayer/CanvasSingleMode'
import ThreeTicketCanvas from '~/components/LaunchWeek/13/ThreeTicketCanvas'
import { Tunnel } from '~/components/LaunchWeek/14/Tunnel'
interface Props {
user: UserData
users: UserData[]
user: UserTicketData
ogImageUrl: string
}
export default function UsernamePage({ user, ogImageUrl }: Props) {
const { username, name, platinum, secret } = user
const ticketType = secret ? 'secret' : platinum ? 'platinum' : 'regular'
const DISPLAY_NAME = name || username
const TITLE = `${DISPLAY_NAME ? DISPLAY_NAME.split(' ')[0] + 's' : 'Get your'} Launch Week Ticket`
const DESCRIPTION = `Claim your Supabase Launch Week 13 ticket for a chance to win supa swag.`
const [session] = useState<Session | null>(null)
const [ticketState, setTicketState] = useState<TicketState>('ticket')
const transition = DEFAULT_TRANSITION
const initial = INITIAL_BOTTOM
const animate = getAnimation({ duration: 1 })
const exit = { opacity: 0, transition: { ...transition, duration: 0.2 } }
const Lw14Page = ({ user, ogImageUrl }: Props) => {
const username = user?.username
const TITLE = `${LW14_TITLE} | ${LW14_DATE}`
const DESCRIPTION = 'Join us for a week of announcing new features, every day at 7 AM PT.'
const PAGE_URL = `${LW14_URL}/tickets/${username}`
if (!username) {
return <Error statusCode={404} />
@@ -49,101 +31,54 @@ export default function UsernamePage({ user, ogImageUrl }: Props) {
<>
<NextSeo
title={TITLE}
description={DESCRIPTION}
openGraph={{
title: TITLE,
description: DESCRIPTION,
url: `${LW_URL}/tickets/${username}`,
url: PAGE_URL,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
height: 628,
},
],
}}
/>
<ConfDataContext.Provider
value={{
supabase,
session,
userData: user,
setUserData: () => null,
ticketState,
setTicketState,
}}
>
<DefaultLayout className="lg:h-[calc(100dvh-65px)] min-h-[calc(100vh)] md:min-h-[calc(100vh-65px)] overflow-hidden">
<SectionContainer className="relative h-full flex-1 pt-4 md:pt-4 pointer-events-none">
<div className="relative z-10 flex h-full">
<LazyMotion features={domAnimation}>
<AnimatePresence mode="wait" key={ticketState}>
<m.div
key="ticket"
initial={initial}
animate={animate}
exit={exit}
className="w-full flex-1 h-full flex flex-col lg:flex-row items-center lg:justify-center lg:items-center gap-8 md:gap-10 lg:gap-32 text-foreground text-center md:text-left"
>
<div className="w-full lg:w-full h-full mt-3 md:mt-6 lg:mt-0 max-w-lg flex flex-col items-center justify-center gap-3"></div>
<div className="lg:h-full w-full max-w-lg gap-8 flex flex-col items-center justify-center lg:items-start lg:justify-center text-center lg:text-left">
<div className="flex flex-col items-center justify-center lg:justify-start lg:items-start gap-2 text-foreground text-center md:text-left max-w-sm">
<h1 className="text-foreground text-2xl">
Join {DISPLAY_NAME?.split(' ')[0]} <br /> for Launch Week 13
</h1>
<span className="text-foreground-lighter">
Claim your own ticket for a chance to win limited swag and to follow all
the announcements.
</span>
</div>
<div>
<Button type="primary" asChild size="small">
<Link
href={`${LW_URL}${username ? '?referral=' + username : ''}`}
className="pointer-events-auto"
>
Claim your ticket
</Link>
</Button>
</div>
</div>
</m.div>
</AnimatePresence>
</LazyMotion>
</div>
</SectionContainer>
<CanvasSingleMode />
<ThreeTicketCanvas
username={DISPLAY_NAME ?? ''}
className="relative -mt-40 -mb-20 lg:my-0 lg:absolute"
ticketPosition="left"
ticketType={ticketType}
sharePage={true}
/>
<Lw14ConfDataProvider initState={{ partymodeStatus: 'on' }}>
<DefaultLayout className='font-["Departure_Mono"] lg:pt-32 border-b pb-0 md:pb-16 lg:!pb-[230px]'>
<LwView />
</DefaultLayout>
</ConfDataContext.Provider>
</Lw14ConfDataProvider>
</>
)
}
export default Lw14Page
export const getStaticProps: GetStaticProps = async ({ params }) => {
const username = params?.username?.toString() || null
let user
const supabaseAdmin = createClient<Database>(
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.LIVE_SUPABASE_COM_SERVICE_ROLE_KEY!
)
// fetch the normal ticket
// stores the og images in supabase storage
fetch(`${SITE_ORIGIN}/api-v2/ticket-og?username=${encodeURIComponent(username ?? '')}`)
fetch(
// @ts-ignore
`${SITE_ORIGIN}/api-v2/ticket-og?username=${encodeURIComponent(username ?? '')}`
)
// fetch a specific user
if (username) {
const { data } = await supabaseAdmin!
const { data, error } = await supabaseAdmin!
.from('tickets_view')
.select('name, username, ticket_number, metadata, platinum, secret, role, company, location')
.eq('launch_week', 'lw13')
.eq('launch_week', 'lw14')
.eq('username', username)
.single()
@@ -164,14 +99,11 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
}
const ticketType = user?.secret ? 'secret' : user?.platinum ? 'platinum' : 'regular'
const ogImageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw13/og/${ticketType}/${username}.png?t=${dayjs(new Date()).format('DHHmmss')}`
const ogImageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/launch-week/lw14/og/${ticketType}/${username}.png?t=${dayjs(new Date()).format('DHHmmss')}`
return {
props: {
user: {
...user,
username,
},
user,
ogImageUrl,
key: username,
},

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

View File

@@ -0,0 +1,36 @@
import Countdown from 'react-countdown'
import { CountdownWidget } from '../CountdownWidget'
const CountdownComponent = ({
date,
showCard = true,
}: {
date: string | number | Date
showCard?: boolean
}) => {
if (!date) return null
const renderer = ({ days, hours, minutes, seconds, completed }: any) => {
if (completed) {
// Render a completed state
return null
} else {
// Render countdown
return (
<CountdownWidget
days={days}
hours={hours}
minutes={minutes}
seconds={seconds}
showCard={showCard}
className="text-emerald-400 [text-shadow:0_0_15px_rgba(52,211,153,0.8)]"
dividerClassName="text-emerald-400 [text-shadow:0_0_15px_rgba(52,211,153,0.8)]"
/>
)
}
}
return <Countdown date={new Date(date)} renderer={renderer} />
}
export default CountdownComponent

View File

@@ -0,0 +1,10 @@
import { Announcement } from 'ui/src/layout/banners'
import LW14Banner from './LW14Banner'
export const LW14Announcement = () => {
return (
<Announcement show={true} announcementKey="announcement_lw14_countdown">
<LW14Banner />
</Announcement>
)
}

View File

@@ -2,22 +2,21 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Button } from '../../../components/Button/Button'
import { cn } from '../../../lib/utils/cn'
import announcement from '../data/Announcement.json'
import announcement from './data.json'
import Image from 'next/image'
import { useTheme } from 'next-themes'
// import Countdown from './Countdown'
import { Button } from 'ui/src/components/Button'
import { cn } from 'ui'
const LW13BGDark =
const LW14BGDark =
'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/images/launch-week/lw13/assets/lw13-banner-dark.png?t=2024-11-22T23%3A10%3A37.646Z'
const LW13BGLight =
const LW14BGLight =
'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/images/launch-week/lw13/assets/lw13-banner-light.png?t=2024-11-22T23%3A10%3A37.646Z'
export function LW13CountdownBanner() {
export function LW14Banner() {
const pathname = usePathname()
const { resolvedTheme } = useTheme()
const bgImage = resolvedTheme?.includes('dark') ? LW13BGDark : LW13BGLight
const bgImage = resolvedTheme?.includes('dark') ? LW14BGDark : LW14BGLight
const isHomePage = pathname === '/'
const isLaunchWeekPage =
pathname === '/launch-week' || pathname?.includes('/launch-week/tickets/')
@@ -27,7 +26,7 @@ export function LW13CountdownBanner() {
if (isLaunchWeekPage || isHomePage) return null
return (
<div className="relative w-full p-2 flex items-center group justify-center text-foreground bg-alternative border-b border-muted transition-colors overflow-hidden">
<div className='relative font-["Departure_Mono"] w-full p-2 flex items-center group justify-center text-foreground bg-alternative border-b border-muted transition-colors overflow-hidden'>
<div className="relative z-10 flex items-center justify-center">
<div
className={cn(
@@ -35,7 +34,7 @@ export function LW13CountdownBanner() {
isLaunchWeekSection && '!justify-center'
)}
>
<p className="flex gap-1.5 items-center">{announcement.text}</p>
<p className="flex gap-1.5 items-center ">{announcement.text}</p>
<div className="hidden sm:block text-foreground-lighter">A week of new features</div>
<Button size="tiny" type="default" className="px-2 !leading-none text-xs" asChild>
<Link href={announcement.link}>{announcement.cta}</Link>
@@ -55,4 +54,4 @@ export function LW13CountdownBanner() {
)
}
export default LW13CountdownBanner
export default LW14Banner

View File

@@ -0,0 +1,6 @@
{
"text": "Launch Week 14",
"launchDate": "2025-03-31T07:00:00.000-08:00",
"link": "/launch-week",
"cta": "Learn more"
}

View File

@@ -8,6 +8,7 @@ interface CountdownWidgetProps {
seconds?: string
showCard?: boolean
className?: string
dividerClassName?: string
size?: 'small' | 'large'
}
@@ -18,11 +19,20 @@ export function CountdownWidget({
seconds,
showCard = true,
className,
dividerClassName,
size,
}: CountdownWidgetProps) {
const isLarge = size === 'large'
const Colon = () => (
<span className={cn('text-xs mx-px text-foreground-lighter', isLarge && 'text-lg')}>:</span>
<span
className={cn(
'text-xs mx-px text-foreground-lighter',
isLarge && 'text-lg',
dividerClassName
)}
>
:
</span>
)
return (

View File

@@ -7,17 +7,15 @@ import { cn } from 'ui/src/lib/utils/cn'
import { Button } from 'ui/src/components/Button/Button'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useTheme } from 'next-themes'
import announcement from 'ui/src/layout/banners/data/Announcement.json'
import announcement from '../Banners/data.json'
import CountdownComponent from '../Banners/Countdown'
import './styles.css'
const LW13BGDark =
'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/images/launch-week/lw13/assets/lw13-bg-dark.png?t=2024-11-22T23%3A10%3A37.646Z'
const LW13BGLight =
'https://xguihxuzqibwxjnimxev.supabase.co/storage/v1/object/public/images/launch-week/lw13/assets/lw13-bg-light.png?t=2024-11-22T23%3A10%3A37.646Z'
const LW14BG = `/docs/img/launchweek/14/promo-banner-bg.png`
const PromoToast = () => {
const [visible, setVisible] = useState(false)
const { resolvedTheme } = useTheme()
const bgImage = resolvedTheme?.includes('dark') ? LW13BGDark : LW13BGLight
useEffect(() => {
const shouldHide =
@@ -43,20 +41,19 @@ const PromoToast = () => {
visible && 'opacity-100 translate-y-0'
)}
>
<div className="relative z-10 text-foreground-lighter leading-3 flex flex-col font-mono uppercase tracking-wide w-full text-xs">
<div className="text-foreground uppercase tracking-wider text-lg -mb-1">Launch Week 13</div>
<p className="text-foreground-lighter uppercase tracking-wider text-xl md:text-lg leading-snug">
26 Dec
<div className="relative z-10 text-foreground-lighter uppercase flex flex-col text-sm w-full font-mono mb-2">
<span className="mb-1">31 APR - 04 MAR / 7AM PT</span>
<p className="relative z-10 text-foreground flex flex-col text-xl w-full leading-7 font-['Departure_Mono']">
Launch Week 14
</p>
</div>
<div className="relative z-10 text-foreground-lighter flex flex-col text-sm uppercase font-mono tracking-widest w-full -mt-1">
A week of new features
<CountdownComponent date={new Date(announcement.launchDate)} showCard={false} />
</div>
<div className="relative z-10 flex items-center space-x-2 mt-2">
<div className="relative z-10 flex items-center space-x-2">
<Button asChild type="secondary">
<Link target="_blank" rel="noreferrer" href={`https://supabase.com${announcement.link}`}>
Learn more
Claim ticket
</Link>
</Button>
<Button type="default" onClick={handleHide}>
@@ -64,7 +61,7 @@ const PromoToast = () => {
</Button>
</div>
<Image
src={bgImage}
src={LW14BG}
alt=""
fill
sizes="100%"

View File

@@ -0,0 +1,16 @@
/* Font face definitions for Launch Week 14 */
@font-face {
font-family: 'Departure Mono';
src: url('/fonts/launchweek/14/DepartureMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Nippo-Variable';
src: url('/fonts/launchweek/14/Nippo-Variable.woff2') format('woff2');
font-weight: 400 700;
font-style: normal;
font-display: swap;
}

View File

@@ -28,6 +28,7 @@
"monaco-editor": "*",
"next-themes": "*",
"openai": "^4.20.1",
"react-countdown": "^2.3.5",
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.45.0",
"react-intersection-observer": "^9.8.2",

View File

@@ -5,7 +5,6 @@ import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { PropsWithChildren } from 'react'
import { cn } from '../../lib/utils/cn'
import _announcement from './data/Announcement.json'
import { X } from 'lucide-react'
export interface AnnouncementProps {
@@ -16,12 +15,11 @@ export interface AnnouncementProps {
badge?: string
}
const announcement = _announcement as AnnouncementProps
interface AnnouncementComponentProps {
show?: boolean
dismissable?: boolean
className?: string
announcementKey: `announcement_${string}`
}
const Announcement = ({
@@ -29,6 +27,7 @@ const Announcement = ({
dismissable = true,
className,
children,
announcementKey,
}: PropsWithChildren<AnnouncementComponentProps>) => {
const [hidden, setHidden] = useState(true)
@@ -36,19 +35,19 @@ const Announcement = ({
const isLaunchWeekSection = pathname?.includes('launch-week') ?? false
// override to hide announcement
if (!show || !announcement.show) return null
if (!show) return null
// construct the key for the announcement, based on the title text
const announcementKey = 'announcement_' + announcement.text.replace(/ /g, '')
const announcementKeyNoSpaces = announcementKey.replace(/ /g, '')
// window.localStorage is kept inside useEffect
// to prevent error
useEffect(function () {
if (window.localStorage.getItem(announcementKey) === 'hidden') {
if (window.localStorage.getItem(announcementKeyNoSpaces) === 'hidden') {
setHidden(true)
}
if (!window.localStorage.getItem(announcementKey)) {
if (!window.localStorage.getItem(announcementKeyNoSpaces)) {
setHidden(false)
}
}, [])
@@ -56,7 +55,7 @@ const Announcement = ({
function handleClose(event: any) {
event.stopPropagation()
window.localStorage.setItem(announcementKey, 'hidden')
window.localStorage.setItem(announcementKeyNoSpaces, 'hidden')
return setHidden(true)
}

View File

@@ -1,7 +0,0 @@
{
"show": true,
"text": "Launch Week 13",
"launchDate": "2024-12-02T07:00:00.000-08:00",
"link": "/launch-week",
"cta": "Learn more"
}

8
pnpm-lock.yaml generated
View File

@@ -1206,6 +1206,9 @@ importers:
animejs:
specifier: ^3.2.2
version: 3.2.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
classnames:
specifier: ^2.3.1
version: 2.3.2
@@ -1958,6 +1961,9 @@ importers:
react:
specifier: '*'
version: 18.2.0
react-countdown:
specifier: ^2.3.5
version: 2.3.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
react-dom:
specifier: '*'
version: 18.2.0(react@18.2.0)
@@ -22514,7 +22520,7 @@ snapshots:
browserslist@4.23.3:
dependencies:
caniuse-lite: 1.0.30001651
caniuse-lite: 1.0.30001695
electron-to-chromium: 1.5.13
node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3)

View File

@@ -4,3 +4,5 @@ values
('New York', 'USA', 'lw12', now(), true),
('London', 'UK', 'lw12', now(), true),
('Singapore', 'Singapore', 'lw12', now(), true);
insert into public.launch_weeks (id) values ('lw14');