💄(home) have a cleaner homepage layout

- try a new layout for the homepage, making it easier to join a meeting
- add a new Dialog component to easily build dialogs
This commit is contained in:
Emmanuel Pelletier
2024-07-22 00:44:02 +02:00
parent b03bfe94a4
commit 57b8a15642
24 changed files with 436 additions and 66 deletions

View File

@@ -11,6 +11,7 @@
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
"i18next": "23.12.1",
@@ -3177,6 +3178,14 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
}
},
"node_modules/@remixicon/react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@remixicon/react/-/react-4.2.0.tgz",
"integrity": "sha512-eGhKpZ88OU0qkcY9pJu6khBmItDV82nU130E6C68yc+FbljueHlUYy/4CrJsmf860RIDMay2Rpzl27OSJ81miw==",
"peerDependencies": {
"react": ">=18.2.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",

View File

@@ -14,6 +14,7 @@
"@livekit/components-react": "2.3.3",
"@livekit/components-styles": "1.0.12",
"@pandacss/preset-panda": "0.41.0",
"@remixicon/react": "4.2.0",
"@tanstack/react-query": "5.49.2",
"hoofd": "1.7.1",
"i18next": "23.12.1",

View File

@@ -179,7 +179,7 @@ const config: Config = {
hover: { value: '{colors.gray.200}' },
active: { value: '{colors.gray.300}' },
text: { value: '{colors.default.text}' },
border: { value: '{colors.gray.300}' },
border: { value: '{colors.gray.500}' },
},
primary: {
DEFAULT: { value: '{colors.blue.700}' },
@@ -232,9 +232,17 @@ const config: Config = {
paragraph: { value: '{spacing.1}' },
heading: { value: '{spacing.1}' },
gutter: { value: '{spacing.1}' },
textfield: { value: '{spacing.0.5}' },
},
}),
textStyles: defineTextStyles({
display: {
value: {
fontSize: '3rem',
lineHeight: '2rem',
fontWeight: 700,
},
},
h1: {
value: {
fontSize: '1.5rem',
@@ -253,7 +261,6 @@ const config: Config = {
value: {
fontSize: '1.125rem',
lineHeight: '1.75rem',
fontWeight: 700,
},
},
body: {
@@ -262,7 +269,7 @@ const config: Config = {
lineHeight: '1.5',
},
},
small: {
sm: {
value: {
fontSize: '0.875rem',
lineHeight: '1.25rem',

View File

@@ -1,5 +1,18 @@
import { useTranslation } from 'react-i18next'
import { A, Button, Italic, P, Div, H, Box } from '@/primitives'
import { navigate } from 'wouter/use-browser-location'
import { Form } from 'react-aria-components'
import { HStack } from '@/styled-system/jsx'
import {
Button,
P,
Div,
Text,
H,
VerticallyOffCenter,
Dialog,
TextField,
} from '@/primitives'
import { useCloseDialog } from '@/primitives/Dialog'
import { authUrl, useUser } from '@/features/auth'
import { navigateToNewRoom } from '@/features/rooms'
import { Screen } from '@/layout/Screen'
@@ -8,30 +21,85 @@ export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
return (
<Screen>
<Box type="screen">
<H lvl={1}>{t('heading')}</H>
<P>{t('intro')}</P>
<Div marginBottom="gutter">
<Box variant="subtle" size="sm">
{isLoggedIn ? (
<Button variant="primary" onPress={() => navigateToNewRoom()}>
{t('createMeeting')}
<Screen type="splash">
<VerticallyOffCenter>
<Div margin="auto" width="fit-content">
<Text as="h1" variant="display">
{t('heading')}
</Text>
<Text as="p" variant="h3">
{t('intro')}
</Text>
{!isLoggedIn && (
<Text margin="sm" variant="note">
{t('loginToCreateMeeting')}
</Text>
)}
<HStack gap="gutter">
<Button
variant="primary"
onPress={isLoggedIn ? () => navigateToNewRoom() : undefined}
href={isLoggedIn ? undefined : authUrl()}
>
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
</Button>
<Dialog title={t('joinMeeting')}>
<Button variant="primary" outline>
{t('joinMeeting')}
</Button>
) : (
<p>
<A href={authUrl()}>{t('login')}</A>
</p>
)}
</Box>
<JoinMeetingDialogContent />
</Dialog>
</HStack>
</Div>
<P>
<Italic>{t('or')}</Italic>
</P>
<Box variant="subtle" size="sm">
<p>{t('copyMeetingUrl')}</p>
</Box>
</Box>
</VerticallyOffCenter>
</Screen>
)
}
const JoinMeetingDialogContent = () => {
const { t } = useTranslation('home')
const closeDialog = useCloseDialog()
const fieldOk = /^.*([a-z]{3}-[a-z]{4}-[a-z]{3})$/
return (
<Div>
<Form
onSubmit={(event) => {
event.preventDefault()
const roomInput = document.getElementById(
'join-meeting-input'
) as HTMLInputElement
const value = roomInput.value
const matches = value.match(fieldOk)
if (matches) {
navigate(`/${matches[1]}`)
}
}}
>
<TextField
id="join-meeting-input"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
if (!fieldOk.test(value)) {
return t('joinInputError')
}
return null
}}
/>
<HStack gap="gutter">
<Button type="submit" variant="primary">
{t('joinInputSubmit')}
</Button>
<Button variant="primary" outline onPress={closeDialog}>
{t('cancel', { ns: 'global' })}
</Button>
</HStack>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
<P last>{t('joinMeetingTipContent')}</P>
</Div>
)
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Box as BoxDiv, H, Link } from '@/primitives'
import { Box as BoxDiv, H, Link, VerticallyOffCenter } from '@/primitives'
export type BoxProps = {
children?: ReactNode
@@ -15,16 +15,18 @@ export const Box = ({
}: BoxProps) => {
const { t } = useTranslation()
return (
<BoxDiv type="screen">
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="small">
{t('backToHome')}
</Link>
</p>
)}
</BoxDiv>
<VerticallyOffCenter>
<BoxDiv type="screen">
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="sm">
{t('backToHome')}
</Link>
</p>
)}
</BoxDiv>
</VerticallyOffCenter>
)
}

View File

@@ -20,7 +20,6 @@ export const Header = () => {
borderBottomStyle: 'solid',
padding: 1,
flexShrink: 0,
boxShadow: 'box',
})}
>
<Stack direction="row" justify="space-between" align="center">
@@ -36,7 +35,7 @@ export const Header = () => {
{!!user && (
<p className={flex({ gap: 1, align: 'center' })}>
<Badge>{user.email}</Badge>
<A href={logoutUrl()} size="small">
<A href={logoutUrl()} size="sm">
{t('logout')}
</A>
</p>

View File

@@ -2,22 +2,30 @@ import type { ReactNode } from 'react'
import { css } from '@/styled-system/css'
import { Header } from './Header'
export const Screen = ({ children }: { children?: ReactNode }) => {
export const Screen = ({
type,
children,
}: {
type?: 'splash'
children?: ReactNode
}) => {
return (
<div
className={css({
height: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: 'default.bg',
backgroundColor: type === 'splash' ? 'white' : 'default.bg',
color: 'default.text',
})}
>
<Header />
{type !== 'splash' && <Header />}
<main
className={css({
flexGrow: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
})}
>
{children}

View File

@@ -1,6 +1,8 @@
{
"app": "Meet",
"backToHome": "",
"cancel": "",
"closeDialog": "",
"error": {
"heading": ""
},

View File

@@ -1,8 +1,13 @@
{
"copyMeetingUrl": "",
"createMeeting": "",
"heading": "",
"intro": "",
"login": "",
"or": ""
"joinInputError": "",
"joinInputExample": "",
"joinInputLabel": "",
"joinInputSubmit": "",
"joinMeeting": "",
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": ""
}

View File

@@ -1,6 +1,8 @@
{
"app": "Meet",
"backToHome": "Back to homescreen",
"cancel": "Cancel",
"closeDialog": "Close dialog",
"error": {
"heading": "An error occured while loading the page"
},
@@ -15,6 +17,6 @@
"login": "Login",
"logout": "Logout",
"notFound": {
"heading": ""
"heading": "Page not found"
}
}

View File

@@ -1,8 +1,13 @@
{
"copyMeetingUrl": "copy a meeting URL in your browser address bar to join an existing conference call",
"createMeeting": "Create a conference call",
"createMeeting": "Create a meeting",
"heading": "Welcome in Meet",
"intro": "What do you want to do? You can either:",
"login": "Login to create a conference call",
"or": "Or"
"intro": "Work easily, from anywhere.",
"joinInputError": "Use a meeting link or its code. Examples: \"https://meet.numerique.gouv.fr/aze-rtyu-iop\" or \"aze-rtyu-iop\".",
"joinInputExample": "URL or 10-letter code",
"joinInputLabel": "Meeting link",
"joinInputSubmit": "Join meeting",
"joinMeeting": "Join a meeting",
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting"
}

View File

@@ -1,6 +1,8 @@
{
"app": "Meet",
"backToHome": "Retour à l'accueil",
"cancel": "Annuler",
"closeDialog": "Fermer la fenêtre modale",
"error": {
"heading": "Une erreur est survenue lors du chargement de la page"
},

View File

@@ -1,8 +1,13 @@
{
"copyMeetingUrl": "copier une URL de conférence dans votre barre d'adresse pour rejoindre une conférence existante",
"createMeeting": "Créer une conférence",
"heading": "Bienvenue dans Meet",
"intro": "Que voulez vous faire ? Vous pouvez :",
"login": "Vous connecter pour créer une conférence",
"or": "Ou"
"heading": "Meet",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien de conférence valide ou uniquement son code. Exemples : \"https://meet.numerique.gouv.fr/aze-rtyu-iop\" ou \"aze-rtyu-iop\".",
"joinInputExample": "Lien complet ou code de 10 lettres",
"joinInputLabel": "Lien de la conférence",
"joinInputSubmit": "Rejoindre la conférence",
"joinMeeting": "Rejoindre une conférence",
"joinMeetingTipContent": "Vous pouvez rejoindre une conférence en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une conférence"
}

View File

@@ -16,8 +16,8 @@ const link = cva({
},
variants: {
size: {
small: {
textStyle: 'small',
sm: {
textStyle: 'sm',
},
},
},

View File

@@ -0,0 +1,5 @@
import { Dialog, type DialogProps } from './Dialog'
export const AlertDialog = (props: DialogProps) => {
return <Dialog role="alertdialog" {...props} />
}

View File

@@ -10,7 +10,7 @@ const badge = cva({
},
variants: {
size: {
small: {
sm: {
textStyle: 'badge',
},
normal: {},

View File

@@ -3,6 +3,7 @@ import { styled } from '../styled-system/jsx'
const box = cva({
base: {
position: 'relative',
gap: 'gutter',
borderRadius: 8,
padding: 'boxPadding',
@@ -14,13 +15,16 @@ const box = cva({
margin: 'auto',
width: '38rem',
maxWidth: '100%',
marginTop: '6rem',
textAlign: 'center',
},
popover: {
padding: 'boxPadding.xs',
minWidth: '10rem',
},
dialog: {
width: '30rem',
maxWidth: '100%',
},
},
variant: {
default: {
@@ -29,7 +33,6 @@ const box = cva({
borderColor: 'box.border',
backgroundColor: 'box.bg',
color: 'box.text',
boxShadow: 'box',
},
subtle: {
color: 'default.subtle-text',

View File

@@ -33,6 +33,9 @@ const button = cva({
paddingX: '0.5',
paddingY: '0.25',
},
xs: {
borderRadius: 4,
},
},
variant: {
default: {
@@ -55,6 +58,12 @@ const button = cva({
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
},
},
},
defaultVariants: {
size: 'default',

View File

@@ -0,0 +1,123 @@
import { useContext, type ReactNode } from 'react'
import {
Dialog as RACDialog,
DialogTrigger,
Modal,
type DialogProps as RACDialogProps,
ModalOverlay,
Heading,
OverlayTriggerStateContext,
} from 'react-aria-components'
import { RiCloseLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
import { Div } from './Div'
import { VerticallyOffCenter } from './VerticallyOffCenter'
import { text } from './Text'
import { Button } from './Button'
import { useTranslation } from 'react-i18next'
const StyledModalOverlay = styled(ModalOverlay, {
base: {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 1000,
},
})
// disabled pointerEvents on the stuff surrouding the overlay is there so that clicking on the overlay to close the modal still works
const StyledModal = styled(Modal, {
base: {
width: 'full',
height: 'full',
pointerEvents: 'none',
},
})
const StyledRACDialog = styled(RACDialog, {
base: {
width: 'full',
height: 'full',
pointerEvents: 'none',
},
})
export type DialogProps = {
title: string
children: [
trigger: ReactNode,
dialogContent:
| (({ close }: { close: () => void }) => ReactNode)
| ReactNode,
]
} & RACDialogProps
/**
* a Dialog is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a Dialog on top of the app
*/
export const Dialog = ({ title, children, ...dialogProps }: DialogProps) => {
const { t } = useTranslation()
const isAlert = dialogProps['role'] === 'alertdialog'
const [trigger, dialogContent] = children
return (
<DialogTrigger>
{trigger}
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
isDismissable={!isAlert}
>
<StyledModal>
<StyledRACDialog {...dialogProps}>
{({ close }) => (
<VerticallyOffCenter>
<Div
width="fit-content"
maxWidth="full"
margin="auto"
pointerEvents="auto"
>
<Box size="sm" type="dialog">
<Heading
slot="title"
level={1}
className={text({ variant: 'h1' })}
>
{title}
</Heading>
{typeof dialogContent === 'function'
? dialogContent({ close })
: dialogContent}
{!isAlert && (
<Div position="absolute" top="0" right="0">
<Button
invisible
size="xs"
onPress={() => close()}
aria-label={t('closeDialog')}
>
<RiCloseLine />
</Button>
</Div>
)}
</Box>
</Div>
</VerticallyOffCenter>
)}
</StyledRACDialog>
</StyledModal>
</StyledModalOverlay>
</DialogTrigger>
)
}
export const useCloseDialog = () => {
const dialogState = useContext(OverlayTriggerStateContext)!
return dialogState.close
}

View File

@@ -1,5 +1,7 @@
import { Text, type As } from './Text'
export const P = (props: React.HTMLAttributes<HTMLElement> & As) => {
export const P = (
props: React.HTMLAttributes<HTMLElement> & As & { last?: boolean }
) => {
return <Text as="p" variant="paragraph" {...props} />
}

View File

@@ -1,21 +1,30 @@
import type { HTMLAttributes } from 'react'
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
const text = cva({
export const text = cva({
base: {},
variants: {
variant: {
h1: {
textStyle: 'h1',
marginBottom: 'heading',
'&:not(:first-child)': {
paddingTop: 'heading',
},
},
h2: {
textStyle: 'h2',
marginBottom: 'heading',
'&:not(:first-child)': {
paddingTop: 'heading',
},
},
h3: {
textStyle: 'h3',
marginBottom: 'heading',
'&:not(:first-child)': {
paddingTop: 'heading',
},
},
body: {
textStyle: 'body',
@@ -24,8 +33,15 @@ const text = cva({
textStyle: 'body',
marginBottom: 'paragraph',
},
small: {
textStyle: 'small',
display: {
textStyle: 'display',
marginBottom: 'heading',
},
sm: {
textStyle: 'sm',
},
note: {
color: 'default.subtle-text',
},
inherits: {},
},
@@ -46,6 +62,14 @@ const text = cva({
false: {
margin: '0!',
},
sm: {
marginBottom: 0.5,
},
},
last: {
true: {
marginBottom: '0!',
},
},
},
defaultVariants: {

View File

@@ -0,0 +1,62 @@
import { styled } from '@/styled-system/jsx'
import {
Label,
Input,
TextField as RACTextField,
Text,
FieldError,
} from 'react-aria-components'
const StyledRACTextField = styled(RACTextField, {
base: {
marginBottom: 'textfield',
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
},
})
const StyledInput = styled(Input, {
base: {
width: 'full',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
},
})
const StyledDescription = styled(Text, {
base: {
display: 'block',
textStyle: 'sm',
color: 'default.subtle-text',
marginTop: 0.125,
},
})
const StyledFieldError = styled(FieldError, {
base: {
display: 'block',
textStyle: 'sm',
color: 'danger',
marginTop: 0.125,
},
})
export const TextField = ({ validate, label, description, ...inputProps }) => {
const labelFor = inputProps.id
return (
<StyledRACTextField validate={validate}>
<StyledLabel htmlFor={labelFor}>{label}</StyledLabel>
<StyledInput {...inputProps} />
<StyledDescription slot="description">{description}</StyledDescription>
<StyledFieldError />
</StyledRACTextField>
)
}

View File

@@ -0,0 +1,23 @@
import { type ReactNode } from 'react'
import { Div } from './Div'
/**
* Renders children almost vertically centered (a bit closer to the top).
*
* This is useful because most of the time we want "vertically centered" things,
* we actually want them to be a bit closer to the top,
* as a perfectly centered box of content really looks off
*/
export const VerticallyOffCenter = ({ children }: { children: ReactNode }) => {
return (
<Div display="flex" flexDirection="column" width="full" height="full">
{/* make sure we can't click on those empty layout-specific divs,
to prevent click issues for example on dialog modal overlays */}
<Div flex="1 1 35%" pointerEvents="none" />
<Div width="full" flex="1 1 100%">
{children}
</Div>
<Div flex="1 1 65%" pointerEvents="none" />
</Div>
)
}

View File

@@ -1,8 +1,10 @@
export { A } from './A'
export { AlertDialog } from './AlertDialog'
export { Badge } from './Badge'
export { Bold } from './Bold'
export { Box } from './Box'
export { Button } from './Button'
export { Dialog } from './Dialog'
export { Div } from './Div'
export { H } from './H'
export { Hr } from './Hr'
@@ -12,3 +14,5 @@ export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { TextField } from './TextField'
export { VerticallyOffCenter } from './VerticallyOffCenter'