♻️(frontend) reorganize starting frontend code

- we now have "features" to try to organize code by intent instead of
code type. everything at the root of frontend, not in feature/, is
global
- customized the panda config a bunch to try to begin to have an actual
design system. The idea is to prevent using arbitrary values here and
there in the code, but rather semantic tokens
- changed the userAuth code logic to handle the fact that a 401 on the
users/me call is not really an error per say, but rather an indication
the user is not logged in
This commit is contained in:
Emmanuel Pelletier
2024-07-11 18:16:18 +02:00
parent d9ef64c4c4
commit 31ea621e44
55 changed files with 985 additions and 484 deletions

View File

@@ -4,8 +4,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Route, Switch } from 'wouter'
import { Home } from './routes/Home'
import { Conference } from './routes/Conference'
import { NotFoundScreen } from './layout/NotFoundScreen'
import { NotFound } from './routes/NotFound'
import { RoomRoute } from '@/features/rooms'
const queryClient = new QueryClient()
@@ -14,8 +14,8 @@ function App() {
<QueryClientProvider client={queryClient}>
<Switch>
<Route path="/" component={Home} />
<Route path="/:roomId" component={Conference} />
<Route component={NotFoundScreen} />
<Route path="/:roomId" component={RoomRoute} />
<Route component={NotFound} />
</Switch>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>

View File

@@ -1,12 +1,11 @@
export const apiUrl = (path: string, apiVersion = '1.0') => {
const origin =
import.meta.env.VITE_API_BASE_URL
|| (typeof window !== 'undefined' ? window.location.origin : '');
import.meta.env.VITE_API_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
// Remove leading/trailing slashes from origin/path if it exists
const sanitizedOrigin = origin.replace(/\/$/, '')
const sanitizedPath = path.replace(/^\//, '');
const sanitizedPath = path.replace(/^\//, '')
return `${sanitizedOrigin}/api/v${apiVersion}/${sanitizedPath}`;
return `${sanitizedOrigin}/api/v${apiVersion}/${sanitizedPath}`
}

View File

@@ -1,6 +0,0 @@
import { ApiRoom } from './ApiRoom'
import { fetchApi } from './fetchApi'
export const fetchRoom = (roomId: string) => {
return fetchApi<ApiRoom>(`/rooms/${roomId}`)
}

View File

@@ -1,6 +0,0 @@
import type { ApiUser } from './ApiUser'
import { fetchApi } from './fetchApi'
export const fetchUser = () => {
return fetchApi<ApiUser>('/users/me')
}

View File

@@ -0,0 +1,25 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { type ApiUser } from './ApiUser'
/**
* fetch the logged in user from the api.
*
* If the user is not logged in, the api returns a 401 error.
* Here our wrapper just returns false in that case, without triggering an error:
* this is done to prevent unnecessary query retries with react query
*/
export const fetchUser = (): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me')
.then(resolve)
.catch((error) => {
// we assume that a 401 means the user is not logged in
if (error instanceof ApiError && error.statusCode === 401) {
resolve(false)
} else {
reject(error)
}
})
})
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
})
return {
...query,
// if fetchUser returns false, it means the user is not logged in: expose that
user: query.data === false ? undefined : query.data,
isLoggedIn: query.data !== undefined && query.data !== false,
}
}

View File

@@ -0,0 +1,2 @@
export { useUser } from './api/useUser'
export { authUrl } from './utils/authUrl'

View File

@@ -0,0 +1,5 @@
import { apiUrl } from '@/api/apiUrl'
export const authUrl = () => {
return apiUrl('/authenticate')
}

View File

@@ -0,0 +1,20 @@
import { ApiError } from '@/api/ApiError'
import { type ApiRoom } from './ApiRoom'
import { fetchApi } from '@/api/fetchApi'
export const fetchRoom = ({
roomId,
username = '',
}: {
roomId: string
username?: string
}) => {
return fetchApi<ApiRoom>(
`/rooms/${roomId}?username=${encodeURIComponent(username)}`
).then((room) => {
if (!room.livekit?.token || !room.livekit?.url) {
throw new ApiError(500, 'LiveKit info not found')
}
return room
})
}

View File

@@ -0,0 +1,48 @@
import { useParams } from 'wouter'
import { useQuery } from '@tanstack/react-query'
import {
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { keys } from '@/api/queryKeys'
import { QueryAware } from '@/layout/QueryAware'
import { navigateToHome } from '@/navigation/navigateToHome'
import { fetchRoom } from '../api/fetchRoom'
export const Conference = ({
userConfig,
}: {
userConfig: LocalUserChoices
}) => {
const { roomId } = useParams()
const { status, data } = useQuery({
queryKey: [keys.room, roomId, userConfig.username],
queryFn: () =>
fetchRoom({
roomId: roomId as string,
username: userConfig.username,
}),
})
return (
<QueryAware status={status}>
<LiveKitRoom
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={{
deviceId: userConfig.audioDeviceId,
}}
video={{
deviceId: userConfig.videoDeviceId,
}}
onDisconnected={() => {
navigateToHome()
}}
>
<VideoConference />
</LiveKitRoom>
</QueryAware>
)
}

View File

@@ -0,0 +1,17 @@
import { Box } from '@/layout/Box'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
export const Join = ({
onSubmit,
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
return (
<Box title="Verify your settings before joining" withBackButton>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
/>
</Box>
)
}

View File

@@ -0,0 +1,2 @@
export { navigateToNewRoom } from './navigation/navigateToNewRoom'
export { Room as RoomRoute } from './routes/Room'

View File

@@ -0,0 +1,6 @@
import { navigate } from 'wouter/use-browser-location'
import { generateRoomId } from '../utils/generateRoomId'
export const navigateToNewRoom = () => {
navigate(`/${generateRoomId()}`)
}

View File

@@ -0,0 +1,18 @@
import { type LocalUserChoices } from '@livekit/components-react'
import { useState } from 'react'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Screen } from '@/layout/Screen'
export const Room = () => {
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
return (
<Screen>
{userConfig ? (
<Conference userConfig={userConfig} />
) : (
<Join onSubmit={setUserConfig} />
)}
</Screen>
)
}

View File

@@ -0,0 +1,5 @@
import { slugify } from '@/utils/slugify'
export const generateRoomId = () => {
return slugify(crypto.randomUUID())
}

View File

@@ -0,0 +1,28 @@
import type { ReactNode } from 'react'
import { Box as BoxDiv, H, Link } from '@/primitives'
export type BoxProps = {
children?: ReactNode
title?: ReactNode
withBackButton?: boolean
}
export const Box = ({
children,
title = '',
withBackButton = false,
}: BoxProps) => {
return (
<BoxDiv asScreen>
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="small">
Back to homescreen
</Link>
</p>
)}
</BoxDiv>
)
}

View File

@@ -1,30 +1,10 @@
import type { ReactNode } from 'react'
import classNames from 'classnames'
import { css } from '@/styled-system/css'
import { Screen } from './Screen'
import { Box, type BoxProps } from './Box'
export const BoxScreen = ({ children }: { children: ReactNode }) => {
export const BoxScreen = (props: BoxProps) => {
return (
<Screen>
<div
className={classNames(
css({
width: '38rem',
maxWidth: '100%',
margin: 'auto',
border: '1px solid #ddd',
borderRadius: 'lg',
backgroundColor: 'white',
boxShadow: 'sm',
textAlign: 'center',
marginTop: '6',
gap: '1',
padding: '2',
})
)}
>
{children}
</div>
<Box {...props} />
</Screen>
)
}

View File

@@ -1,17 +1,7 @@
import { Link } from 'wouter'
import { A } from '@/primitives/A'
import { H1 } from '@/primitives/H'
import { BoxScreen } from './BoxScreen'
export const ErrorScreen = () => {
return (
<BoxScreen>
<H1>An error occured while loading the page</H1>
<p>
<Link to="/" asChild>
<A size="small">Back to homescreen</A>
</Link>
</p>
</BoxScreen>
<BoxScreen title="An error occured while loading the page" withBackButton />
)
}

View File

@@ -1,17 +1,10 @@
import { Link } from 'wouter'
import { A } from '@/primitives/A'
import { H1 } from '@/primitives/H'
import { BoxScreen } from './BoxScreen'
export const ForbiddenScreen = () => {
return (
<BoxScreen>
<H1>You don't have the permission to view this page</H1>
<p>
<Link to="/" asChild>
<A size="small">Back to homescreen</A>
</Link>
</p>
</BoxScreen>
<BoxScreen
title="You don't have the permission to view this page"
withBackButton
/>
)
}

View File

@@ -1,19 +1,22 @@
import { apiUrl } from '@/api/apiUrl'
import { A } from '@/primitives/A'
import { useUser } from '@/queries/useUser'
import { css } from '@/styled-system/css'
import { flex } from '@/styled-system/patterns'
import { apiUrl } from '@/api/apiUrl'
import { A, Badge, Text } from '@/primitives'
import { useUser } from '@/features/auth/api/useUser'
export const Header = () => {
const { user, isLoggedIn } = useUser()
return (
<div
className={css({
backgroundColor: 'white',
padding: '1',
backgroundColor: 'primary.text',
color: 'primary',
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
padding: 1,
flexShrink: 0,
color: '#000091',
boxShadow: '#00000040 0px 1px 4px',
boxShadow: 'box',
})}
>
<header
@@ -23,20 +26,18 @@ export const Header = () => {
})}
>
<div>
<p
className={css({
fontWeight: 'bold',
fontSize: '2xl',
})}
>
<Text bold variant="h1" margin={false}>
Meet
</p>
</Text>
</div>
<div>
{isLoggedIn === false && <A href={apiUrl('/authenticate')}>Login</A>}
{!!user && (
<p>
{user.email}&nbsp;&nbsp;<A href={apiUrl('/logout')}>Logout</A>
<p className={flex({ gap: 1, align: 'center' })}>
<Badge>{user.email}</Badge>
<A href={apiUrl('/logout')} size="small">
Logout
</A>
</p>
)}
</div>

View File

@@ -1,17 +1,5 @@
import { Link } from 'wouter'
import { A } from '@/primitives/A'
import { H1 } from '@/primitives/H'
import { BoxScreen } from './BoxScreen'
export const NotFoundScreen = () => {
return (
<BoxScreen>
<H1>Page not found</H1>
<p>
<Link to="/" asChild>
<A size="small">Back to homescreen</A>
</Link>
</p>
</BoxScreen>
)
return <BoxScreen title="Page not found" withBackButton />
}

View File

@@ -0,0 +1,20 @@
import { ErrorScreen } from './ErrorScreen'
import { LoadingScreen } from './LoadingScreen'
export const QueryAware = ({
status,
children,
}: {
status: 'error' | 'pending' | 'success'
children: React.ReactNode
}) => {
if (status === 'error') {
return <ErrorScreen />
}
if (status === 'pending') {
return <LoadingScreen />
}
return children
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from '@/layout/Header'
import { Header } from './Header'
export const Screen = ({ children }: { children: ReactNode }) => {
return (
@@ -9,7 +9,8 @@ export const Screen = ({ children }: { children: ReactNode }) => {
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'slate.50',
backgroundColor: 'default.bg',
color: 'default.text',
})}
>
<Header />

View File

@@ -0,0 +1,5 @@
import { navigate } from 'wouter/use-browser-location'
export const navigateToHome = () => {
navigate(`/`)
}

View File

@@ -1,8 +1,5 @@
import { RecipeVariantProps, cva } from '@/styled-system/css'
import {
Link as Link,
type LinkProps as LinksProps,
} from 'react-aria-components'
import { Link, type LinkProps } from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
const link = cva({
base: {
@@ -10,25 +7,27 @@ const link = cva({
textUnderlineOffset: '2',
transition: 'all 200ms',
cursor: 'pointer',
_hover: {
'_ra-hover': {
textDecoration: 'none',
},
_pressed: {
'_ra-pressed': {
textDecoration: 'underline',
},
},
variants: {
size: {
small: {
fontSize: 'sm',
textStyle: 'small',
},
},
},
})
export const A = ({
size,
...props
}: LinksProps & RecipeVariantProps<typeof link>) => {
export type AProps = LinkProps & RecipeVariantProps<typeof link>
/**
* anchor component styled with underline
*/
export const A = ({ size, ...props }: AProps) => {
return <Link {...props} className={link({ size })} />
}

View File

@@ -0,0 +1,29 @@
import { cva, type RecipeVariantProps } from '@/styled-system/css'
const badge = cva({
base: {
display: 'inline-block',
padding: '0.25rem 0.5rem',
backgroundColor: 'primary.subtle',
color: 'primary.subtle-text',
borderRadius: '6',
},
variants: {
size: {
small: {
textStyle: 'badge',
},
normal: {},
},
},
defaultVariants: {
size: 'normal',
},
})
export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> &
RecipeVariantProps<typeof badge>
export const Badge = ({ size, ...props }: BadgeProps) => {
return <span {...props} className={badge({ size })} />
}

View File

@@ -1,9 +1,5 @@
import { css } from '@/styled-system/css'
import { Text, type As } from './Text'
const bold = css({
fontWeight: 'bold',
})
export const Bold = (props: React.HTMLAttributes<HTMLSpanElement>) => {
return <strong className={bold} {...props} />
export const Bold = (props: React.HTMLAttributes<HTMLElement> & As) => {
return <Text as="strong" {...props} bold />
}

View File

@@ -0,0 +1,51 @@
import { cva } from '@/styled-system/css'
import { styled } from '../styled-system/jsx'
const box = cva({
base: {
gap: 'gutter',
borderRadius: 8,
padding: 'boxPadding',
flex: 1,
},
variants: {
asScreen: {
true: {
margin: 'auto',
width: '38rem',
maxWidth: '100%',
marginTop: '6rem',
textAlign: 'center',
},
},
variant: {
default: {
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'box.border',
backgroundColor: 'box.bg',
color: 'box.text',
boxShadow: 'box',
},
subtle: {
color: 'default.subtle-text',
backgroundColor: 'default.subtle',
},
},
size: {
default: {
padding: 'boxPadding',
},
sm: {
padding: 'boxPadding.sm',
},
},
},
defaultVariants: {
asScreen: false,
variant: 'default',
size: 'default',
},
})
export const Box = styled('div', box)

View File

@@ -1,8 +1,58 @@
import {
Button as RACButton,
type ButtonProps as RACButtonsProps,
Link,
LinkProps,
} from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
export const Button = (props: RACButtonsProps) => {
return <RACButton {...props} className="lk-button" />
const button = cva({
base: {
display: 'inline-block',
paddingX: '1',
paddingY: '0.625',
transition: 'all 200ms',
borderRadius: 8,
cursor: 'pointer',
},
variants: {
variant: {
default: {
color: 'control.text',
backgroundColor: 'control',
'_ra-hover': {
backgroundColor: 'control.hover',
},
'_ra-pressed': {
backgroundColor: 'control.active',
},
},
primary: {
color: 'primary.text',
backgroundColor: 'primary',
'_ra-hover': {
backgroundColor: 'primary.hover',
},
'_ra-pressed': {
backgroundColor: 'primary.active',
},
},
},
},
})
type ButtonProps = RecipeVariantProps<typeof button> &
(RACButtonsProps | LinkProps)
export const Button = (props: ButtonProps) => {
const [variantProps, componentProps] = button.splitVariantProps(props)
if ((props as LinkProps).href !== undefined) {
return <Link className={button(variantProps)} {...componentProps} />
}
return (
<RACButton
className={button(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
)
}

View File

@@ -0,0 +1,3 @@
import { Box } from '@/styled-system/jsx'
export const Div = Box

View File

@@ -1,24 +1,14 @@
import type { HTMLAttributes } from 'react'
import classNames from 'classnames'
import { css } from '@/styled-system/css'
import { Text } from './Text'
export const H1 = ({
export const H = ({
children,
className,
lvl,
...props
}: HTMLAttributes<HTMLHeadingElement>) => {
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => {
const tag = `h${lvl}` as const
return (
<h1
className={classNames(
css({
textStyle: '2xl',
marginBottom: '1',
}),
className
)}
{...props}
>
<Text as={tag} variant={tag} {...props}>
{children}
</h1>
</Text>
)
}

View File

@@ -1,10 +1,13 @@
import { css } from '@/styled-system/css'
const hr = css({
marginY: '1',
borderColor: 'neutral.300',
})
export const Hr = (props: React.HTMLAttributes<HTMLHRElement>) => {
return <hr className={hr} {...props} />
return (
<hr
className={css({
marginY: 1,
borderColor: 'neutral.300',
})}
{...props}
/>
)
}

View File

@@ -0,0 +1,5 @@
import { Text, type As } from './Text'
export const Italic = (props: React.HTMLAttributes<HTMLElement> & As) => {
return <Text as="em" {...props} italic />
}

View File

@@ -0,0 +1,18 @@
import { Link as WouterLink } from 'wouter'
import { A, type AProps } from './A'
/**
* Wouter link wrapper to use our A primitive
*/
export const Link = ({
to,
...props
}: {
to: string
} & AProps) => {
return (
<WouterLink to={to} asChild>
<A {...props} />
</WouterLink>
)
}

View File

@@ -1,23 +1,5 @@
import type { HTMLAttributes } from 'react'
import classNames from 'classnames'
import { css } from '@/styled-system/css'
import { Text, type As } from './Text'
export const P = ({
children,
className,
...props
}: HTMLAttributes<HTMLParagraphElement>) => {
return (
<p
className={classNames(
css({
marginBottom: '1',
}),
className
)}
{...props}
>
{children}
</p>
)
export const P = (props: React.HTMLAttributes<HTMLElement> & As) => {
return <Text as="p" variant="paragraph" {...props} />
}

View File

@@ -0,0 +1,78 @@
import type { HTMLAttributes } from 'react'
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
const text = cva({
base: {},
variants: {
variant: {
h1: {
textStyle: 'h1',
marginBottom: 'heading',
},
h2: {
textStyle: 'h2',
marginBottom: 'heading',
},
h3: {
textStyle: 'h3',
marginBottom: 'heading',
},
body: {
textStyle: 'body',
},
paragraph: {
textStyle: 'body',
marginBottom: 'paragraph',
},
small: {
textStyle: 'small',
},
inherits: {},
},
bold: {
true: {
fontWeight: 'bold',
},
false: {
fontWeight: 'normal',
},
},
italic: {
true: {
fontStyle: 'italic',
},
},
margin: {
false: {
margin: '0!',
},
},
},
defaultVariants: {
variant: 'inherits',
},
})
type TextHTMLProps = HTMLAttributes<HTMLElement>
type TextElement =
| 'h1'
| 'h2'
| 'h3'
| 'h4'
| 'h5'
| 'h6'
| 'p'
| 'span'
| 'strong'
| 'em'
| 'div'
export type As = { as?: TextElement }
export type TextProps = RecipeVariantProps<typeof text> & TextHTMLProps & As
export function Text(props: TextProps) {
const [variantProps, componentProps] = text.splitVariantProps(props)
const { as: Component = 'p', className, ...tagProps } = componentProps
return (
<Component className={cx(text(variantProps), className)} {...tagProps} />
)
}

View File

@@ -0,0 +1,12 @@
export { A } from './A'
export { Badge } from './Badge'
export { Bold } from './Bold'
export { Box } from './Box'
export { Button } from './Button'
export { Div } from './Div'
export { H } from './H'
export { Hr } from './Hr'
export { Italic } from './Italic'
export { Link } from './Link'
export { P } from './P'
export { Text } from './Text'

View File

@@ -1,30 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { keys } from './keys'
import { fetchUser } from '@/api/fetchUser'
export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retryOnMount: false,
retry: (_, error) => {
return error.statusCode !== 401
},
})
let isLoggedIn
if (query.status === 'success' && query.data?.email !== null) {
isLoggedIn = true
}
if (query.status === 'error' && query.failureReason?.statusCode === 401) {
isLoggedIn = false
}
return {
...query,
user: query.data,
isLoggedIn,
}
}

View File

@@ -1,81 +0,0 @@
import { fetchRoom } from '@/api/fetchRoom'
import { BoxScreen } from '@/layout/BoxScreen'
import { ErrorScreen } from '@/layout/ErrorScreen'
import { ForbiddenScreen } from '@/layout/ForbiddenScreen'
import { LoadingScreen } from '@/layout/LoadingScreen'
import { Screen } from '@/layout/Screen'
import { A } from '@/primitives/A'
import { H1 } from '@/primitives/H'
import { keys } from '@/queries/keys'
import {
LiveKitRoom,
VideoConference,
PreJoin,
LocalUserChoices,
} from '@livekit/components-react'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { Link, useLocation, useParams } from 'wouter'
export const Conference = () => {
const { roomId } = useParams()
const [, navigate] = useLocation()
const { status, data } = useQuery({
queryKey: [keys.room, roomId],
queryFn: ({ queryKey }) => fetchRoom(queryKey[1] as string),
})
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
if (!userConfig) {
return (
<BoxScreen>
<H1>Verify your settings before joining</H1>
<PreJoin
persistUserChoices
onSubmit={(choices) => {
setUserConfig(choices)
}}
/>
<p>
<Link to="/" asChild>
<A size="small">Back to homescreen</A>
</Link>
</p>
</BoxScreen>
)
}
if (status === 'error' || (status === 'success' && !data?.livekit)) {
return <ErrorScreen />
}
if (data?.is_public === false) {
return <ForbiddenScreen />
}
if (data?.livekit?.token && data?.livekit?.url) {
return (
<Screen>
<LiveKitRoom
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={{
deviceId: userConfig.audioDeviceId,
}}
video={{
deviceId: userConfig.videoDeviceId,
}}
onDisconnected={() => {
navigate('/')
}}
>
<VideoConference />
</LiveKitRoom>
</Screen>
)
}
return <LoadingScreen />
}

View File

@@ -1,49 +1,41 @@
import { useLocation } from 'wouter'
import { useUser } from '@/queries/useUser'
import { Button } from '@/primitives/Button'
import { A } from '@/primitives/A'
import { Bold } from '@/primitives/Bold'
import { H1 } from '@/primitives/H'
import { P } from '@/primitives/P'
import { Hr } from '@/primitives/Hr'
import { A, Button, Italic, P, Div, H, Box } from '@/primitives'
import { useUser } from '@/features/auth'
import { apiUrl } from '@/api/apiUrl'
import { createRandomRoom } from '@/utils/createRandomRoom'
import { LoadingScreen } from '@/layout/LoadingScreen'
import { BoxScreen } from '@/layout/BoxScreen'
import { navigateToNewRoom } from '@/features/rooms'
import { Screen } from '@/layout/Screen'
export const Home = () => {
const { status, isLoggedIn } = useUser()
const [, navigate] = useLocation()
if (status === 'pending') {
return <LoadingScreen asBox />
}
const { isLoggedIn } = useUser()
return (
<BoxScreen>
<H1>
Welcome in <Bold>Meet</Bold>!
</H1>
{isLoggedIn ? (
<Button onPress={() => navigate(`/${createRandomRoom()}`)}>
Create a conference call
</Button>
) : (
<>
<P>What do you want to do? You can either:</P>
<Hr aria-hidden="true" />
<P>
<A href={apiUrl('/authenticate')}>
Login to create a conference call
</A>
</P>
<Hr aria-hidden="true" />
<P>
<span style={{ fontStyle: 'italic' }}>Or </span> copy a URL in your
browser address bar to join an existing conference call
</P>
</>
)}
</BoxScreen>
<Screen>
<Box asScreen>
<H lvl={1}>Welcome in Meet</H>
<P>What do you want to do? You can either:</P>
<Div marginBottom="gutter">
<Box variant="subtle" size="sm">
{isLoggedIn ? (
<Button variant="primary" onPress={() => navigateToNewRoom()}>
Create a conference call
</Button>
) : (
<p>
<A href={apiUrl('/authenticate')}>
Login to create a conference call
</A>
</p>
)}
</Box>
</Div>
<P>
<Italic>Or</Italic>
</P>
<Box variant="subtle" size="sm">
<p>
copy a meeting URL in your browser address bar to join an existing
conference call
</p>
</Box>
</Box>
</Screen>
)
}

View File

@@ -1,8 +1,5 @@
import { NotFoundScreen } from '@/layout/NotFoundScreen'
export const NotFound = () => {
return (
<div>
<h1>404</h1>
<p>Page not found</p>
</div>
)
return <NotFoundScreen />
}

View File

@@ -0,0 +1,43 @@
@font-face {
font-family: 'Source Sans';
src: url('/fonts/sourcesans3-regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Source Sans';
src: url('/fonts/sourcesans3-it-subset.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Source Sans';
src: url('/fonts/sourcesans3-bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Source Code Pro';
src: url('/fonts/sourcecodepro-regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/*
* to reduce CLS
* values taken from https://github.com/khempenius/font-fallbacks-dataset/blob/main/font-metric-overrides.csv#L2979
*/
@font-face {
font-family: 'Source Sans fallback';
src: local('Arial');
ascent-override: 98.4%;
descent-override: 27.3%;
line-gap-override: 0%;
}

View File

@@ -1,3 +1,4 @@
@import './fonts.css';
@import './livekit.css';
@layer reset, base, tokens, recipes, utilities;
html,

View File

@@ -1,114 +1,57 @@
/* based on https://github.com/livekit/components-js/blob/main/packages/styles/scss/themes/default.scss
for now only "visio-light" is actually used
*/
[data-lk-theme='visio-dark'] {
color-scheme: dark;
--lk-bg: #111;
--lk-bg2: #1e1e1e;
--lk-bg3: #2b2b2b;
--lk-bg4: #373737;
--lk-bg5: #444444;
--lk-fg: #fff;
--lk-fg2: whitesmoke;
--lk-fg3: #ebebeb;
--lk-fg4: #e0e0e0;
--lk-fg5: #d6d6d6;
--lk-border-color: rgba(255, 255, 255, 0.1);
--lk-accent-fg: #fff;
--lk-accent-bg: #1f8cf9;
--lk-accent2: #3396fa;
--lk-accent3: #47a0fa;
--lk-accent4: #5babfb;
--lk-danger-fg: #fff;
--lk-danger: #842029;
--lk-danger2: #b02a37;
--lk-danger3: #dc3545;
--lk-danger4: #e35d6a;
--lk-success-fg: #fff;
--lk-success: #146c43;
--lk-success2: #198754;
--lk-success3: #479f76;
--lk-success4: #75b798;
--lk-control-fg: var(--lk-fg);
--lk-control-bg: var(--lk-bg2);
--lk-control-hover-bg: var(--lk-bg3);
--lk-control-active-bg: var(--lk-bg4);
--lk-control-active-hover-bg: var(--lk-bg5);
--lk-connection-excellent: #198754;
--lk-connection-good: #fd7e14;
--lk-connection-poor: #dc3545;
--lk-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
--lk-font-size: 16px;
--lk-line-height: 1.5;
--lk-border-radius: 0.5rem;
--lk-box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15);
--lk-grid-gap: 0.5rem;
--lk-control-bar-height: 69px;
--lk-chat-header-height: 69px;
--lk-bg6: #777;
--lk-control-border-width: 1px;
--lk-control-border-color: var(--lk-bg5);
--lk-control-border: var(--lk-control-border-color);
--lk-control-hover-border: var(--lk-bg6);
}
[data-lk-theme='visio-light'] {
color-scheme: light;
--lk-bg: #fff;
--lk-bg2: #f3f4f6;
--lk-bg3: #e5e7eb;
--lk-bg4: #d1d5db;
--lk-bg5: #9ca3af;
--lk-fg: #111;
--lk-fg2: #18181b;
--lk-fg3: #27272a;
--lk-fg4: #3f3f46;
--lk-bg: var(--colors-white);
--lk-room-bg: var(--colors-default-bg);
--lk-bg2: var(--colors-gray-100);
--lk-bg3: var(--colors-gray-200);
--lk-bg4: var(--colors-gray-300);
--lk-bg5: var(--colors-gray-400);
--lk-fg: var(--colors-text);
--lk-fg5: #52525b;
--lk-border-color: rgba(255, 255, 255, 0.1);
--lk-accent-fg: #fff;
--lk-accent-bg: #1f8cf9;
--lk-accent2: #3396fa;
--lk-accent3: #47a0fa;
--lk-accent4: #5babfb;
--lk-danger-fg: var(--lk-fg);
--lk-danger: #f8d7da;
--lk-danger2: #f1aeb5;
--lk-danger3: #ea868f;
--lk-danger4: #e35d6a;
--lk-success-fg: #fff;
--lk-success: #146c43;
--lk-success2: #198754;
--lk-success3: #479f76;
--lk-success4: #75b798;
--lk-control-fg: var(--lk-fg);
--lk-control-bg: var(--lk-bg2);
--lk-control-hover-bg: var(--lk-bg3);
--lk-control-active-bg: var(--lk-bg4);
--lk-control-active-hover-bg: var(--lk-bg5);
--lk-connection-excellent: #198754;
--lk-connection-good: #fd7e14;
--lk-connection-poor: #dc3545;
--lk-font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
--lk-font-size: 16px;
--lk-line-height: 1.5;
--lk-border-radius: 0.5rem;
--lk-box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.15);
--lk-grid-gap: 0.5rem;
--lk-border-color: var(--colors-gray-400);
--lk-accent-fg: var(--colors-primary-text);
--lk-accent-bg: var(--colors-primary);
--lk-accent2: var(--colors-primary-hover);
--lk-accent3: var(--colors-primary-active);
--lk-accent4: var(--colors-primary-active);
--lk-danger-fg: var(--colors-danger-text);
--lk-danger: var(--colors-danger);
--lk-danger-hover-bg: var(--colors-danger-hover);
--lk-danger-active-bg: var(--colors-danger-active);
--lk-success-fg: var(--colors-success-text);
--lk-success: var(--colors-success);
--lk-control-fg: var(--colors-control-text);
--lk-control-bg: var(--colors-control);
--lk-control-hover-bg: var(--colors-control-hover);
--lk-control-toggled-on-bg: var(--colors-control-hover);
--lk-control-active-bg: var(--colors-control-active);
--lk-control-active-hover-bg: var(--colors-control-active);
--lk-connection-excellent: var(--colors-success);
--lk-connection-good: var(--colors-warning);
--lk-connection-poor: var(--colors-danger);
--lk-line-height: var(--line-heights-1\.5);
--lk-border-radius: var(--radii-8);
--lk-box-shadow: var(--shadows-sm);
--lk-grid-gap: 1.5rem;
--lk-control-bar-height: 69px;
--lk-chat-header-height: 69px;
--lk-font-family: var(--fonts-sans);
--lk-font-size: 1rem;
--lk-bg6: #6b7280;
--lk-control-border-width: 1px;
--lk-control-border-color: var(--lk-bg5);
--lk-control-border: var(--lk-control-border-color);
--lk-control-hover-border: var(--lk-bg6);
--lk-controlbar-bg: var(--colors-gray-300);
--lk-participant-border: var(--colors-gray-400);
}
[data-lk-theme] {
background-color: var(--lk-bg);
[data-lk-theme] .lk-room-container {
background-color: var(--lk-room-bg);
}
.lk-button,
@@ -133,14 +76,77 @@ for now only "visio-light" is actually used
border-color: var(--lk-control-hover-border);
}
.lk-disconnect-button {
--lk-control-border: var(--lk-danger3);
.lk-button:not(:disabled):active,
.lk-start-audio-button:not(:disabled):active,
.lk-close-button:not(:disabled):active,
.lk-chat-toggle:not(:disabled):active,
.lk-button:not(:disabled):is([data-pressed]) {
background-color: var(--lk-control-active-bg);
}
.lk-disconnect-button:not(:disabled):hover {
--lk-control-hover-bg: var(--lk-danger2);
.lk-button[aria-pressed='true'],
[aria-pressed='true'].lk-start-audio-button,
[aria-pressed='true'].lk-chat-toggle,
[aria-pressed='true'].lk-disconnect-button {
background-color: var(--lk-control-toggled-on-bg);
}
.lk-prejoin {
[data-lk-theme] .lk-disconnect-button {
--lk-control-border: var(--colors-danger-hover);
}
[data-lk-theme] .lk-disconnect-button:not(:disabled):hover {
--lk-control-hover-bg: var(--lk-danger-hover-bg);
}
[data-lk-theme] .lk-disconnect-button:not(:disabled):active {
background-color: var(--lk-danger-active-bg);
}
[data-lk-theme='visio-light'] .lk-close-button path {
fill: var(--lk-fg);
}
[data-lk-theme='visio-light'] .lk-participant-metadata-item,
[data-lk-theme='visio-light']
.lk-participant-tile
.lk-focus-toggle-button:not(:hover) {
color: white;
}
[data-lk-theme='visio-light']
.lk-chat-entry[data-lk-message-origin='local']
.lk-message-body {
align-self: flex-end;
background-color: var(--colors-primary-subtle);
}
[data-lk-theme='visio-light']
.lk-chat-entry[data-lk-message-origin='remote']
.lk-message-body {
background-color: var(--colors-blue-300);
}
[data-lk-theme] .lk-chat-header {
font-weight: bold;
}
[data-lk-theme] .lk-chat-messages {
padding: 0.5rem;
}
.lk-chat-entry .lk-meta-data {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
[data-lk-theme] .lk-control-bar {
background-color: var(--lk-controlbar-bg);
}
[data-lk-theme] .lk-prejoin {
padding-top: 0;
}
[data-lk-theme] .lk-participant-tile {
box-shadow: var(--lk-box-shadow);
}

View File

@@ -1,5 +0,0 @@
import { slugify } from './slugify'
export const createRandomRoom = () => {
return slugify(crypto.randomUUID())
}