💄(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:
9
src/frontend/package-lock.json
generated
9
src/frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@livekit/components-react": "2.3.3",
|
"@livekit/components-react": "2.3.3",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.0.12",
|
||||||
"@pandacss/preset-panda": "0.41.0",
|
"@pandacss/preset-panda": "0.41.0",
|
||||||
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.49.2",
|
"@tanstack/react-query": "5.49.2",
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
"i18next": "23.12.1",
|
"i18next": "23.12.1",
|
||||||
@@ -3177,6 +3178,14 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.18.0",
|
"version": "4.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@livekit/components-react": "2.3.3",
|
"@livekit/components-react": "2.3.3",
|
||||||
"@livekit/components-styles": "1.0.12",
|
"@livekit/components-styles": "1.0.12",
|
||||||
"@pandacss/preset-panda": "0.41.0",
|
"@pandacss/preset-panda": "0.41.0",
|
||||||
|
"@remixicon/react": "4.2.0",
|
||||||
"@tanstack/react-query": "5.49.2",
|
"@tanstack/react-query": "5.49.2",
|
||||||
"hoofd": "1.7.1",
|
"hoofd": "1.7.1",
|
||||||
"i18next": "23.12.1",
|
"i18next": "23.12.1",
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const config: Config = {
|
|||||||
hover: { value: '{colors.gray.200}' },
|
hover: { value: '{colors.gray.200}' },
|
||||||
active: { value: '{colors.gray.300}' },
|
active: { value: '{colors.gray.300}' },
|
||||||
text: { value: '{colors.default.text}' },
|
text: { value: '{colors.default.text}' },
|
||||||
border: { value: '{colors.gray.300}' },
|
border: { value: '{colors.gray.500}' },
|
||||||
},
|
},
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: { value: '{colors.blue.700}' },
|
DEFAULT: { value: '{colors.blue.700}' },
|
||||||
@@ -232,9 +232,17 @@ const config: Config = {
|
|||||||
paragraph: { value: '{spacing.1}' },
|
paragraph: { value: '{spacing.1}' },
|
||||||
heading: { value: '{spacing.1}' },
|
heading: { value: '{spacing.1}' },
|
||||||
gutter: { value: '{spacing.1}' },
|
gutter: { value: '{spacing.1}' },
|
||||||
|
textfield: { value: '{spacing.0.5}' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
textStyles: defineTextStyles({
|
textStyles: defineTextStyles({
|
||||||
|
display: {
|
||||||
|
value: {
|
||||||
|
fontSize: '3rem',
|
||||||
|
lineHeight: '2rem',
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
},
|
||||||
h1: {
|
h1: {
|
||||||
value: {
|
value: {
|
||||||
fontSize: '1.5rem',
|
fontSize: '1.5rem',
|
||||||
@@ -253,7 +261,6 @@ const config: Config = {
|
|||||||
value: {
|
value: {
|
||||||
fontSize: '1.125rem',
|
fontSize: '1.125rem',
|
||||||
lineHeight: '1.75rem',
|
lineHeight: '1.75rem',
|
||||||
fontWeight: 700,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
@@ -262,7 +269,7 @@ const config: Config = {
|
|||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
small: {
|
sm: {
|
||||||
value: {
|
value: {
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
lineHeight: '1.25rem',
|
lineHeight: '1.25rem',
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
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 { authUrl, useUser } from '@/features/auth'
|
||||||
import { navigateToNewRoom } from '@/features/rooms'
|
import { navigateToNewRoom } from '@/features/rooms'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -8,30 +21,85 @@ export const Home = () => {
|
|||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn } = useUser()
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen type="splash">
|
||||||
<Box type="screen">
|
<VerticallyOffCenter>
|
||||||
<H lvl={1}>{t('heading')}</H>
|
<Div margin="auto" width="fit-content">
|
||||||
<P>{t('intro')}</P>
|
<Text as="h1" variant="display">
|
||||||
<Div marginBottom="gutter">
|
{t('heading')}
|
||||||
<Box variant="subtle" size="sm">
|
</Text>
|
||||||
{isLoggedIn ? (
|
<Text as="p" variant="h3">
|
||||||
<Button variant="primary" onPress={() => navigateToNewRoom()}>
|
{t('intro')}
|
||||||
{t('createMeeting')}
|
</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>
|
</Button>
|
||||||
) : (
|
<JoinMeetingDialogContent />
|
||||||
<p>
|
</Dialog>
|
||||||
<A href={authUrl()}>{t('login')}</A>
|
</HStack>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Div>
|
</Div>
|
||||||
<P>
|
</VerticallyOffCenter>
|
||||||
<Italic>{t('or')}</Italic>
|
|
||||||
</P>
|
|
||||||
<Box variant="subtle" size="sm">
|
|
||||||
<p>{t('copyMeetingUrl')}</p>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Screen>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 = {
|
export type BoxProps = {
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@@ -15,16 +15,18 @@ export const Box = ({
|
|||||||
}: BoxProps) => {
|
}: BoxProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<BoxDiv type="screen">
|
<VerticallyOffCenter>
|
||||||
{!!title && <H lvl={1}>{title}</H>}
|
<BoxDiv type="screen">
|
||||||
{children}
|
{!!title && <H lvl={1}>{title}</H>}
|
||||||
{!!withBackButton && (
|
{children}
|
||||||
<p>
|
{!!withBackButton && (
|
||||||
<Link to="/" size="small">
|
<p>
|
||||||
{t('backToHome')}
|
<Link to="/" size="sm">
|
||||||
</Link>
|
{t('backToHome')}
|
||||||
</p>
|
</Link>
|
||||||
)}
|
</p>
|
||||||
</BoxDiv>
|
)}
|
||||||
|
</BoxDiv>
|
||||||
|
</VerticallyOffCenter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const Header = () => {
|
|||||||
borderBottomStyle: 'solid',
|
borderBottomStyle: 'solid',
|
||||||
padding: 1,
|
padding: 1,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
boxShadow: 'box',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Stack direction="row" justify="space-between" align="center">
|
<Stack direction="row" justify="space-between" align="center">
|
||||||
@@ -36,7 +35,7 @@ export const Header = () => {
|
|||||||
{!!user && (
|
{!!user && (
|
||||||
<p className={flex({ gap: 1, align: 'center' })}>
|
<p className={flex({ gap: 1, align: 'center' })}>
|
||||||
<Badge>{user.email}</Badge>
|
<Badge>{user.email}</Badge>
|
||||||
<A href={logoutUrl()} size="small">
|
<A href={logoutUrl()} size="sm">
|
||||||
{t('logout')}
|
{t('logout')}
|
||||||
</A>
|
</A>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,22 +2,30 @@ import type { ReactNode } from 'react'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Header } from './Header'
|
import { Header } from './Header'
|
||||||
|
|
||||||
export const Screen = ({ children }: { children?: ReactNode }) => {
|
export const Screen = ({
|
||||||
|
type,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
type?: 'splash'
|
||||||
|
children?: ReactNode
|
||||||
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
backgroundColor: 'default.bg',
|
backgroundColor: type === 'splash' ? 'white' : 'default.bg',
|
||||||
color: 'default.text',
|
color: 'default.text',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Header />
|
{type !== 'splash' && <Header />}
|
||||||
<main
|
<main
|
||||||
className={css({
|
className={css({
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app": "Meet",
|
"app": "Meet",
|
||||||
"backToHome": "",
|
"backToHome": "",
|
||||||
|
"cancel": "",
|
||||||
|
"closeDialog": "",
|
||||||
"error": {
|
"error": {
|
||||||
"heading": ""
|
"heading": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"copyMeetingUrl": "",
|
|
||||||
"createMeeting": "",
|
"createMeeting": "",
|
||||||
"heading": "",
|
"heading": "",
|
||||||
"intro": "",
|
"intro": "",
|
||||||
"login": "",
|
"joinInputError": "",
|
||||||
"or": ""
|
"joinInputExample": "",
|
||||||
|
"joinInputLabel": "",
|
||||||
|
"joinInputSubmit": "",
|
||||||
|
"joinMeeting": "",
|
||||||
|
"joinMeetingTipContent": "",
|
||||||
|
"joinMeetingTipHeading": "",
|
||||||
|
"loginToCreateMeeting": ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app": "Meet",
|
"app": "Meet",
|
||||||
"backToHome": "Back to homescreen",
|
"backToHome": "Back to homescreen",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"closeDialog": "Close dialog",
|
||||||
"error": {
|
"error": {
|
||||||
"heading": "An error occured while loading the page"
|
"heading": "An error occured while loading the page"
|
||||||
},
|
},
|
||||||
@@ -15,6 +17,6 @@
|
|||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"notFound": {
|
"notFound": {
|
||||||
"heading": ""
|
"heading": "Page not found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{
|
{
|
||||||
"copyMeetingUrl": "copy a meeting URL in your browser address bar to join an existing conference call",
|
"createMeeting": "Create a meeting",
|
||||||
"createMeeting": "Create a conference call",
|
|
||||||
"heading": "Welcome in Meet",
|
"heading": "Welcome in Meet",
|
||||||
"intro": "What do you want to do? You can either:",
|
"intro": "Work easily, from anywhere.",
|
||||||
"login": "Login to create a conference call",
|
"joinInputError": "Use a meeting link or its code. Examples: \"https://meet.numerique.gouv.fr/aze-rtyu-iop\" or \"aze-rtyu-iop\".",
|
||||||
"or": "Or"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"app": "Meet",
|
"app": "Meet",
|
||||||
"backToHome": "Retour à l'accueil",
|
"backToHome": "Retour à l'accueil",
|
||||||
|
"cancel": "Annuler",
|
||||||
|
"closeDialog": "Fermer la fenêtre modale",
|
||||||
"error": {
|
"error": {
|
||||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
"createMeeting": "Créer une conférence",
|
||||||
"heading": "Bienvenue dans Meet",
|
"heading": "Meet",
|
||||||
"intro": "Que voulez vous faire ? Vous pouvez :",
|
"intro": "Collaborez en toute simplicité, où que vous soyez.",
|
||||||
"login": "Vous connecter pour créer une conférence",
|
"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\".",
|
||||||
"or": "Ou"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ const link = cva({
|
|||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
small: {
|
sm: {
|
||||||
textStyle: 'small',
|
textStyle: 'sm',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
5
src/frontend/src/primitives/AlertDialog.tsx
Normal file
5
src/frontend/src/primitives/AlertDialog.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Dialog, type DialogProps } from './Dialog'
|
||||||
|
|
||||||
|
export const AlertDialog = (props: DialogProps) => {
|
||||||
|
return <Dialog role="alertdialog" {...props} />
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ const badge = cva({
|
|||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
small: {
|
sm: {
|
||||||
textStyle: 'badge',
|
textStyle: 'badge',
|
||||||
},
|
},
|
||||||
normal: {},
|
normal: {},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { styled } from '../styled-system/jsx'
|
|||||||
|
|
||||||
const box = cva({
|
const box = cva({
|
||||||
base: {
|
base: {
|
||||||
|
position: 'relative',
|
||||||
gap: 'gutter',
|
gap: 'gutter',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 'boxPadding',
|
padding: 'boxPadding',
|
||||||
@@ -14,13 +15,16 @@ const box = cva({
|
|||||||
margin: 'auto',
|
margin: 'auto',
|
||||||
width: '38rem',
|
width: '38rem',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
marginTop: '6rem',
|
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
popover: {
|
popover: {
|
||||||
padding: 'boxPadding.xs',
|
padding: 'boxPadding.xs',
|
||||||
minWidth: '10rem',
|
minWidth: '10rem',
|
||||||
},
|
},
|
||||||
|
dialog: {
|
||||||
|
width: '30rem',
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
default: {
|
default: {
|
||||||
@@ -29,7 +33,6 @@ const box = cva({
|
|||||||
borderColor: 'box.border',
|
borderColor: 'box.border',
|
||||||
backgroundColor: 'box.bg',
|
backgroundColor: 'box.bg',
|
||||||
color: 'box.text',
|
color: 'box.text',
|
||||||
boxShadow: 'box',
|
|
||||||
},
|
},
|
||||||
subtle: {
|
subtle: {
|
||||||
color: 'default.subtle-text',
|
color: 'default.subtle-text',
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const button = cva({
|
|||||||
paddingX: '0.5',
|
paddingX: '0.5',
|
||||||
paddingY: '0.25',
|
paddingY: '0.25',
|
||||||
},
|
},
|
||||||
|
xs: {
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
variant: {
|
variant: {
|
||||||
default: {
|
default: {
|
||||||
@@ -55,6 +58,12 @@ const button = cva({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
invisible: {
|
||||||
|
true: {
|
||||||
|
borderColor: 'none!',
|
||||||
|
backgroundColor: 'none!',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
size: 'default',
|
size: 'default',
|
||||||
|
|||||||
123
src/frontend/src/primitives/Dialog.tsx
Normal file
123
src/frontend/src/primitives/Dialog.tsx
Normal 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
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Text, type As } from './Text'
|
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} />
|
return <Text as="p" variant="paragraph" {...props} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import type { HTMLAttributes } from 'react'
|
import type { HTMLAttributes } from 'react'
|
||||||
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
|
import { RecipeVariantProps, cva, cx } from '@/styled-system/css'
|
||||||
|
|
||||||
const text = cva({
|
export const text = cva({
|
||||||
base: {},
|
base: {},
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
h1: {
|
h1: {
|
||||||
textStyle: 'h1',
|
textStyle: 'h1',
|
||||||
marginBottom: 'heading',
|
marginBottom: 'heading',
|
||||||
|
'&:not(:first-child)': {
|
||||||
|
paddingTop: 'heading',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
textStyle: 'h2',
|
textStyle: 'h2',
|
||||||
marginBottom: 'heading',
|
marginBottom: 'heading',
|
||||||
|
'&:not(:first-child)': {
|
||||||
|
paddingTop: 'heading',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
textStyle: 'h3',
|
textStyle: 'h3',
|
||||||
marginBottom: 'heading',
|
marginBottom: 'heading',
|
||||||
|
'&:not(:first-child)': {
|
||||||
|
paddingTop: 'heading',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
textStyle: 'body',
|
textStyle: 'body',
|
||||||
@@ -24,8 +33,15 @@ const text = cva({
|
|||||||
textStyle: 'body',
|
textStyle: 'body',
|
||||||
marginBottom: 'paragraph',
|
marginBottom: 'paragraph',
|
||||||
},
|
},
|
||||||
small: {
|
display: {
|
||||||
textStyle: 'small',
|
textStyle: 'display',
|
||||||
|
marginBottom: 'heading',
|
||||||
|
},
|
||||||
|
sm: {
|
||||||
|
textStyle: 'sm',
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
color: 'default.subtle-text',
|
||||||
},
|
},
|
||||||
inherits: {},
|
inherits: {},
|
||||||
},
|
},
|
||||||
@@ -46,6 +62,14 @@ const text = cva({
|
|||||||
false: {
|
false: {
|
||||||
margin: '0!',
|
margin: '0!',
|
||||||
},
|
},
|
||||||
|
sm: {
|
||||||
|
marginBottom: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
last: {
|
||||||
|
true: {
|
||||||
|
marginBottom: '0!',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
62
src/frontend/src/primitives/TextField.tsx
Normal file
62
src/frontend/src/primitives/TextField.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/frontend/src/primitives/VerticallyOffCenter.tsx
Normal file
23
src/frontend/src/primitives/VerticallyOffCenter.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export { A } from './A'
|
export { A } from './A'
|
||||||
|
export { AlertDialog } from './AlertDialog'
|
||||||
export { Badge } from './Badge'
|
export { Badge } from './Badge'
|
||||||
export { Bold } from './Bold'
|
export { Bold } from './Bold'
|
||||||
export { Box } from './Box'
|
export { Box } from './Box'
|
||||||
export { Button } from './Button'
|
export { Button } from './Button'
|
||||||
|
export { Dialog } from './Dialog'
|
||||||
export { Div } from './Div'
|
export { Div } from './Div'
|
||||||
export { H } from './H'
|
export { H } from './H'
|
||||||
export { Hr } from './Hr'
|
export { Hr } from './Hr'
|
||||||
@@ -12,3 +14,5 @@ export { P } from './P'
|
|||||||
export { Popover } from './Popover'
|
export { Popover } from './Popover'
|
||||||
export { PopoverList } from './PopoverList'
|
export { PopoverList } from './PopoverList'
|
||||||
export { Text } from './Text'
|
export { Text } from './Text'
|
||||||
|
export { TextField } from './TextField'
|
||||||
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
|
|||||||
Reference in New Issue
Block a user