init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { IconLoader } from '@supabase/ui'
import { FC, RefObject } from 'react'
import { Message } from '../types'
interface Props {
messages: Message[]
chatboxRef: RefObject<any>
messagesInTransit: string[]
areMessagesFetched: boolean
}
const Chatbox: FC<Props> = ({ messages, chatboxRef, messagesInTransit, areMessagesFetched }) => {
return (
<div className="flex flex-col rounded-md break-all max-h-[235px] overflow-y-scroll">
<div
className="space-y-1 py-2 px-4 w-[400px]"
style={{ backgroundColor: 'rgba(0, 207, 144, 0.05)' }}
>
{!areMessagesFetched ? (
<div className="flex items-center space-x-2">
<IconLoader className="animate-spin text-scale-1200" size={14} />
<p className="text-sm text-scale-1100">Loading messages</p>
</div>
) : messages.length === 0 && messagesInTransit.length === 0 ? (
<div className="text-scale-1200 text-sm opacity-75">
<span>Type anything to start chatting 🥳</span>
</div>
) : (
<div />
)}
{messages.map((message) => (
<p key={message.id} className="text-scale-1200 text-sm whitespace-pre-line">
{message.message}
</p>
))}
{messagesInTransit.map((message, idx: number) => (
<p key={`transit-${idx}`} className="text-sm text-scale-1100">
{message}
</p>
))}
<div ref={chatboxRef} className="!mt-0" />
</div>
</div>
)
}
export default Chatbox

View File

@@ -0,0 +1,140 @@
import { FC, FormEvent, useEffect, useRef, useState } from 'react'
interface Props {
x?: number
y?: number
color: string
hue: string
message: string
isTyping: boolean
isCancelled?: boolean
isLocalClient?: boolean
onUpdateMessage?: (message: string) => void
}
const MAX_MESSAGE_LENGTH = 70
const MAX_DURATION = 4000
const MAX_BUBBLE_WIDTH_THRESHOLD = 280 + 50
const MAX_BUBBLE_HEIGHT_THRESHOLD = 40 + 50
const Cursor: FC<Props> = ({
x,
y,
color,
hue,
message,
isTyping,
isCancelled,
isLocalClient,
onUpdateMessage = () => {},
}) => {
// Don't show cursor for the local client
const _isLocalClient = !x || !y || isLocalClient
const inputRef = useRef() as any
const timeoutRef = useRef() as any
const chatBubbleRef = useRef() as any
const [flipX, setFlipX] = useState(false)
const [flipY, setFlipY] = useState(false)
const [hideInput, setHideInput] = useState(false)
const [showMessageBubble, setShowMessageBubble] = useState(false)
useEffect(() => {
if (isTyping) {
setShowMessageBubble(true)
if (timeoutRef.current) clearTimeout(timeoutRef.current)
if (isLocalClient) {
if (inputRef.current) inputRef.current.focus()
setHideInput(false)
}
} else {
if (!message || isCancelled) {
setShowMessageBubble(false)
} else {
if (timeoutRef.current) clearTimeout(timeoutRef.current)
if (isLocalClient) setHideInput(true)
const timeoutId = setTimeout(() => {
setShowMessageBubble(false)
}, MAX_DURATION)
timeoutRef.current = timeoutId
}
}
}, [isLocalClient, isTyping, isCancelled, message, inputRef])
useEffect(() => {
// [Joshen] Experimental: dynamic flipping to ensure that chat
// bubble always stays within the viewport, comment this block
// out if the effect seems weird.
setFlipX((x || 0) + MAX_BUBBLE_WIDTH_THRESHOLD >= window.innerWidth)
setFlipY((y || 0) + MAX_BUBBLE_HEIGHT_THRESHOLD >= window.innerHeight)
}, [x, y, isTyping, chatBubbleRef])
return (
<>
{!_isLocalClient && (
<svg
width="18"
height="24"
viewBox="0 0 18 24"
fill="none"
className="absolute top-0 left-0 transform transition pointer-events-none"
style={{ color, transform: `translateX(${x}px) translateY(${y}px)` }}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.717 2.22918L15.9831 15.8743C16.5994 16.5083 16.1503 17.5714 15.2661 17.5714H9.35976C8.59988 17.5714 7.86831 17.8598 7.3128 18.3783L2.68232 22.7C2.0431 23.2966 1 22.8434 1 21.969V2.92626C1 2.02855 2.09122 1.58553 2.717 2.22918Z"
fill={color}
stroke={hue}
strokeWidth="2"
/>
</svg>
)}
<div
ref={chatBubbleRef}
className={[
'transition-all absolute top-0 left-0 py-2 rounded-full shadow-md',
'flex items-center justify-between px-4 space-x-2 pointer-events-none',
`${showMessageBubble ? 'opacity-100' : 'opacity-0'}`,
`${_isLocalClient && !hideInput ? 'w-[280px]' : 'max-w-[280px] overflow-hidden'}`,
].join(' ')}
style={{
backgroundColor: color,
transform: `translateX(${
(x || 0) + (flipX ? -chatBubbleRef.current.clientWidth - 20 : 20)
}px) translateY(${(y || 0) + (flipY ? -chatBubbleRef.current.clientHeight - 20 : 20)}px)`,
}}
>
{_isLocalClient && !hideInput ? (
<>
<input
ref={inputRef}
value={message}
className="w-full outline-none bg-transparent border-none text-white"
onChange={(e: FormEvent<HTMLInputElement>) => {
const text = e.currentTarget.value
if (text.length <= MAX_MESSAGE_LENGTH) onUpdateMessage(e.currentTarget.value)
}}
/>
<p className="text-xs" style={{ color: hue }}>
{message.length}/{MAX_MESSAGE_LENGTH}
</p>
</>
) : message.length ? (
<div className="truncate text-white">{message}</div>
) : (
<div className="flex items-center justify-center">
<div className="loader-dots block relative h-6 w-10">
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-white opacity-75"></div>
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-white opacity-75"></div>
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-white opacity-75"></div>
<div className="absolute top-0 my-2.5 w-1.5 h-1.5 rounded-full bg-white opacity-75"></div>
</div>
</div>
)}
</div>
</>
)
}
export default Cursor

View File

@@ -0,0 +1,71 @@
import { IconSun, IconMoon } from '@supabase/ui'
import { useEffect } from 'react'
import { useTheme } from '../lib/ThemeProvider'
function DarkModeToggle() {
const { isDarkMode, toggleTheme } = useTheme()
const toggleDarkMode = () => {
localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString())
toggleTheme()
const key = localStorage.getItem('supabaseDarkMode')
document.documentElement.className = key === 'true' ? 'dark' : ''
}
useEffect(() => {
const key = localStorage.getItem('supabaseDarkMode')
if (key && key == 'false') {
document.documentElement.className = ''
}
}, [])
return (
<div className="flex items-center">
<button
type="button"
aria-pressed="false"
className={`
relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer
transition-colors ease-in-out duration-200 focus:outline-none ${
isDarkMode
? 'bg-scale-500 hover:bg-scale-700'
: 'bg-scale-900 hover:bg-scale-1100'
}
`}
onClick={() => toggleDarkMode()}
>
<span className="sr-only">Toggle Themes</span>
<span
aria-hidden="true"
className={`
relative
${
isDarkMode ? 'translate-x-5' : 'translate-x-0'
} inline-block h-5 w-5 rounded-full
bg-white dark:bg-scale-300 shadow-lg transform ring-0 transition ease-in-out duration-200
`}
>
<IconSun
className={
'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-scale-900 ' +
(!isDarkMode ? 'opacity-100' : 'opacity-0')
}
strokeWidth={2}
size={12}
/>
<IconMoon
className={
'absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-scale-900 ' +
(isDarkMode ? 'opacity-100' : 'opacity-0')
}
strokeWidth={2}
size={14}
/>
</span>
</button>
</div>
)
}
export default DarkModeToggle

View File

@@ -0,0 +1,12 @@
const Loader = () => {
return (
<div className="bg-scale-200 h-screen w-screen flex flex-col items-center justify-center space-y-4">
<span className="flex h-5 w-5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-900 opacity-75" />
<span className="relative inline-flex rounded-full h-full w-full bg-green-900" />
</span>
</div>
)
}
export default Loader

View File

@@ -0,0 +1,38 @@
import { FC } from 'react'
import { User } from '../types'
interface Props {
users: Record<string, User>
}
const Users: FC<Props> = ({ users }) => {
return (
<div className="relative">
{Object.entries(users).map(([userId, userData], idx) => {
return (
<div key={userId} className="relative">
<div
key={userId}
className={[
'transition-all absolute right-0 h-8 w-8 bg-scale-1200 rounded-full bg-center bg-[length:50%_50%]',
'bg-no-repeat shadow-md flex items-center justify-center',
].join(' ')}
style={{
border: `1px solid ${userData.hue}`,
background: userData.color,
transform: `translateX(${Math.abs(idx - (Object.keys(users).length - 1)) * -20}px)`,
}}
>
<div
style={{ background: userData.color }}
className="w-7 h-7 animate-ping-once rounded-full"
/>
</div>
</div>
)
})}
</div>
)
}
export default Users

View File

@@ -0,0 +1,180 @@
import { FC, useState, memo } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import {
Button,
Form,
Input,
IconMinimize2,
IconMaximize2,
IconGitHub,
IconTwitter,
} from '@supabase/ui'
import supabaseClient from '../client'
import { useTheme } from '../lib/ThemeProvider'
interface Props {}
const WaitlistPopover: FC<Props> = ({}) => {
const { isDarkMode } = useTheme()
const [isExpanded, setIsExpanded] = useState(true)
const [isSuccess, setIsSuccess] = useState(false)
const [error, setError] = useState<any>()
const initialValues = { email: '' }
const getGeneratedTweet = () => {
return `Join me to experience Realtime by Supabase!%0A%0A${window.location.href}`
}
const onValidate = (values: any) => {
const errors = {} as any
const emailValidateRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/
if (!emailValidateRegex.test(values.email)) errors.email = 'Please enter a valid email'
return errors
}
const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => {
setIsSuccess(false)
setError(undefined)
setSubmitting(true)
const { error } = await supabaseClient.from('waitlist').insert([{ email: values.email }])
if (!error) {
resetForm()
setIsSuccess(true)
} else {
setError(error)
}
setSubmitting(false)
}
return (
<div
className={`bg-scale-200 border border-scale-500 dark:border-scale-300 p-6 rounded-md w-[400px] space-y-8 transition-all ${
isExpanded ? 'max-h-[600px]' : 'max-h-[70px]'
} duration-500 overflow-hidden shadow-2xl dark:shadow-lg`}
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-center space-x-2">
<Image
src={isDarkMode ? `/img/supabase-dark.svg` : `/img/supabase-light.svg`}
alt="supabase"
height={20}
width={100}
/>
<div
className={`transition relative -top-[1px] ${
!isExpanded ? 'opacity-100' : 'opacity-0'
} space-x-2 flex items-center`}
>
<p className={`transition-all text-scale-900 text-sm ${isExpanded ? '-ml-2' : 'ml-0'}`}>
/
</p>
<p
className={`transition-all text-scale-1200 text-sm ${isExpanded ? '-ml-2' : 'ml-0'}`}
>
Realtime
</p>
</div>
</div>
{isExpanded ? (
<IconMinimize2
className="transition-all text-scale-900 cursor-pointer hover:text-scale-1200 hover:scale-105"
strokeWidth={2}
size={16}
onClick={() => setIsExpanded(false)}
/>
) : (
<IconMaximize2
className="transition-all text-scale-900 cursor-pointer hover:text-scale-1200 hover:scale-105"
strokeWidth={2}
size={16}
onClick={() => setIsExpanded(true)}
/>
)}
</div>
<div className="space-y-6">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h1 className="text-scale-1200 text-2xl">Realtime</h1>
</div>
<p className="text-sm text-scale-900">
Realtime collaborative app to display broadcast, presence, and database listening over
WebSockets
</p>
</div>
<div>
<a
href="https://www.producthunt.com/posts/realtime-multiplayer-by-supabase?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-realtime&#0045;multiplayer&#0045;by&#0045;supabase"
rel="noreferrer"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=339695&theme=dark"
alt="Realtime&#0032;Multiplayer&#0032;by&#0032;Supabase - Easily&#0032;build&#0032;real&#0045;time&#0032;apps&#0032;that&#0032;enables&#0032;user&#0032;collaboration | Product Hunt"
style={{ width: '250px', height: '54px' }}
width="250"
height="54"
/>
</a>
</div>
<div className="flex items-center gap-2">
<Link href="https://github.com/supabase/realtime" passHref>
<Button as="a" type="default" icon={<IconGitHub />}>
View on GitHub
</Button>
</Link>
<Link href={`https://twitter.com/intent/tweet?text=${getGeneratedTweet()}`} passHref>
<Button as="a" type="alternative" icon={<IconTwitter />}>
Invite on Twitter
</Button>
</Link>
</div>
</div>
<Form validateOnBlur initialValues={initialValues} validate={onValidate} onSubmit={onSubmit}>
{({ isSubmitting }: any) => {
return (
<>
<Input
id="email"
name="email"
size="small"
placeholder="example@email.com"
autoComplete="off"
actions={[
<Button
className="mr-0.5"
key="submit"
htmlType="submit"
loading={isSubmitting}
disabled={isSubmitting}
>
Get early access
</Button>,
]}
/>
{isSuccess && (
<p className="text-sm text-green-1000 mt-2">
Thank you for submitting your interest!
</p>
)}
{error?.message.includes('duplicate key') && (
<p className="text-sm text-red-900 mt-2">
Email has already been registered for waitlist
</p>
)}
{error && !error?.message.includes('duplicate key') && (
<p className="text-sm text-red-900 mt-2">Unable to register email for waitlist</p>
)}
</>
)
}}
</Form>
</div>
)
}
export default memo(WaitlistPopover)