Compare commits

...

52 Commits

Author SHA1 Message Date
Emmanuel Pelletier 62492d1411 🔖(minor) bump release to 0.1.2 2024-07-29 10:19:58 +02:00
Emmanuel Pelletier 34e21b77b5 🩹(loading) fix header doubled when loading room
the whole screen wrapper thing is not that great… needs improvements to
prevent that kind of stuff
2024-07-29 10:11:31 +02:00
Emmanuel Pelletier 9edb9c2f57 💄(button) center button texts
this is definitely an oversight, button texts should be centered
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier ee1abbed04 🚸(feedback) remember the user about feedback form on call end
add a new route that just tells the user about the feedback form
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier ed52123733 💄(header) better feedback integration
- small screens are now usable (not that great but better than before)
- spacing is more consistent between left and right
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier a7739efb70 🚸(feedback) better wording
"on Meet" was misleading, it was like, "give us feedback by going on
'meet'. Let's be straightforward
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier 72ef3c8bb0 🎨(locales) fix locale strings order
we should definitely have something that crashes on CI if running
i18n:extract makes a diff… oh well
2024-07-29 08:48:44 +02:00
Emmanuel Pelletier e9ef2bc4ae 🐛(home) fix invite form that didn't work with actual meeting urls
it worked with codes but not full urls!
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier 8587574fcd (rooms) show an invite dialog when creating a room
for now the dialog appears as a regular dialog with an overlay and all,
would be better as less intrusive. but good as is as a first step
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier c3617fc005 🚸(home) skip the join screen when creating a room
at the cost of a not-changeable username, logged in users who create
rooms now skip the join page. I think it's good to have this to test
this way, even if the username choice is not there yet.
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier 8f81318ecf ♻️(frontend) trying out cleaner way to handle routes
it was a bit cumbersome to navigate by paths accross the app as if one
path changes a tiny bit we have to make sure we updated everything
everywhere and it's kind of error-prone and cumbersome to build paths by
hand.

now we have a routes file that describes everything: what is the path to
each route, and how to navigate to them.

We use a new navigateTo helper that helps us. It could be better typed
but i'm a newbie.
2024-07-29 08:16:50 +02:00
Emmanuel Pelletier afd2f9a299 (frontend) new feedback link in header
this is just a link to an external url that is, for now, hardcoded as
our deploys dont have frontend env vars right now
2024-07-28 02:49:14 +02:00
lebaudantoine fff5740b14 (backend) fix test broken by dependencies update
Django Rest Framework (DRF) recent updates broke one unit test,
that was checking for a default error message returned by DRF.
Updated the message.
2024-07-25 23:56:59 +02:00
renovate[bot] 7545f94bbd ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-25 23:06:41 +02:00
renovate[bot] edb90327e5 ⬆️(dependencies) update requests to v2.32.2 [SECURITY] 2024-07-25 22:34:45 +02:00
Emmanuel Pelletier fdff5092b0 🐛(sso) prevent flash when trying to autologin
the issue was we return the fetchUser promise to react query too early
(before the browser reloaded the page). now we make sure react query
doesn't get the info
2024-07-25 22:34:18 +02:00
lebaudantoine 234116163a (frontend) try silent login every one hour
This commit is totally work in progress.
@manuhabitela the floor is yours.

If the user is logged-out, try silently log-in her every hour,
to avoid any manual login on her side.

The one hour value has been discussed with @manuhabitela, but there
is no real logic behind it. Could definitely be shorter or longer.

It needs at least to be longer than the average silent login flow
to avoid locking the user-agent in an infinite redirection loop.
2024-07-25 22:34:18 +02:00
lebaudantoine 2a8027deb0 (frontend) support 'silent' and 'returnTo' params in authUrl
Enhanced the authentication URL generation to support 'silent'
and 'returnTo' query parameters.

This allows initiating a silent login and specifying a custom
return URL after auth.
2024-07-25 22:34:18 +02:00
lebaudantoine d167490c09 (backend) support silent login
Silent login attempts to re-authenticate the user without interaction,
provided they have an active session, improving UX by reducing manual auth.

It's an essential feature to really feel the SSO in La Suite.

A new query parameter, 'silent', allows the client to initiate a silent login.
In this flow, an extra parameter, 'prompt=none', is passed to the OIDC provider.

The requested flow is persisted in session data to adapt the authentication
callback behavior.

In a silent login flow, an authentication failure should not be considered as a
real failure. Instead, users should be redirected back to the originating view.
A silent login fails when user has no active session.

Why return the 'success_url'? The 'success_url' will redirect the user agent to
the 'returnTo' parameter provided when requesting authentication.
It's necessary to enable a silent login on any URL.

Minimal test coverage has been added for these two custom views to ensure
correct behavior.
2024-07-25 22:34:18 +02:00
lebaudantoine e7ea700c3d (backend) handle custom redirect URL while login
mozilla-django-oidc now supports a 'returnTo' parameter for redirecting
to a specificURL upon successful login. if not provided,
it defaults to the settings-defined URL.

This allows initiating the login flow from any views,
enhancing UX by returning users to their previous page.

The 'returnTo' naming can be discussed.
2024-07-25 22:34:18 +02:00
lebaudantoine edf19c8f1e 🩹(backend) update pyproject description
Woopsi. I forgot to update the package description while bootstrapping
the project. Fixed!
2024-07-25 22:34:18 +02:00
lebaudantoine e6feed2086 📝(tilt) document running the application with Tilt
Based on @rouja instructions, try to document the Tilt stack,
to enhance the DX of any newcomers, discovering Meet and trying
to run it on K8s.

Having a shared/common onboarding documentation on Tilt with
Impress, Regie, and Meet would be amazing.

Especially, to document how to install Tilt and its dependencies.

Important: The frontend is not deployed locally using the production
target, and I feel important to document it.
2024-07-25 18:56:40 +02:00
lebaudantoine dbd9ac6eea 🩹(frontend) fix ignored pre-join options
Closes #50.

Adopted LiveKit demo app approach for passing
configurations to LiveKitRoom.

Introduced roomOptions for future customizations (e.g.,
video quality, e2e, codec).

DeviceId is persisted in local storage despite boolean flag.
2024-07-25 18:54:19 +02:00
lebaudantoine 86b03a3d47 ⚰️(backend) remove unused cold storage configurations
Minio was removed from our stack, because it wasn't used.
Cleaned up some environment variables.
2024-07-25 18:24:37 +02:00
renovate[bot] 2c2b4edc84 ⬆️(dependencies) update djangorestframework to v3.15.2 [SECURITY] 2024-07-25 18:23:56 +02:00
renovate[bot] 4be2d16483 ⬆️(dependencies) update sentry-sdk to v2 [SECURITY] 2024-07-25 18:23:01 +02:00
lebaudantoine ccd0cb4641 ⬆️(ci) update setup-python actions
setup-python@v3 uses a soon-deprecated Node version.
Updated them to the most recent version.
2024-07-25 18:06:50 +02:00
lebaudantoine 561ea346db ⬆️(ci) update checkout actions
checkout@v2 uses node12 which will be deprecated soon.
I've aligned CI configurations to use a more recent action,
already in-use in the 'meet.yml' flow.
2024-07-25 18:06:50 +02:00
lebaudantoine 13bd195b22 🔥(tsclient) remove tsclient sources
It won't be any useful in the short term, and was broken.
If needed, we would add it back to the stack.
(Opinionated choice, feel free to discuss it)
2024-07-25 16:46:27 +02:00
Emmanuel Pelletier a35a4ffbec 🚸(room) prevent user from misclicking out of a meeting
now clicking on the header homepage link asks for confirmation when on
the route page.

this is quick and dirty, using browser confirm ui, and not making a
difference between join page and conference page, but it'll do for now
2024-07-25 15:05:06 +02:00
Emmanuel Pelletier 668523aa8b 🚸(frontend) follow antoine's remarks on homepage and header
- show the header on homepage. Not sure we want any header on this app
actually but I guess he's right since we have one it feels more
consistent to have it everywhere
- show logged in email in header. ditched it because i didn't quite get
the value of showing it all the time in this app but i guess it's better
than nothing
- remove user info from settings. Since they are back in the header, no
need
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier d15fb0a19b 💄(frontend) fix loading screen not visually centered
the loading word was sticked to top left without any padding
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier d6502f0739 🌐(home) fix obsolete locale string
examples are handled in the code instead of needing translations
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 504b851731 💄(header) just show the settings button in the header
for now! i wanna ditch the header anyway when we rework the conference
view
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier cb6a81715a 💄(boxes) screen boxes are not at all boxes now
this is meant to be updated soon, it's not ideal at all. This Screen/Box
stuff is really meh anyway, needs some rework.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 47c133cc64 ♻️(dialogs) make it easier to have dialogs unrelated to their trigger
- use the default react-aria DialogTrigger when we want to build buttons
triggering dialogs
- use custom Dialog component as a wrapper to Dialog content
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier c0d490f549 (frontend) new Tooltip component
buttons can now easily have tooltip via a new `tooltip` attribute that
generates a Tooltip linked to the button
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 41ad15e20b (frontend) new settings dialog to handle user account/language
add a settings button directly in the homepage to change language or see
user account settings
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 0707ac2cd4 ♻️(dialogs) new DialogContent component to ease up code splitting
this better permits us to have a Dialog content component somewhere else
than its trigger button. Mainly did this so that the dialog title is
localized with its content.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 5ce63d937d ♻️(css) remove '_ra-*' helpers from panda
they are not really helpful, i'd rather stick to the react-aria wording,
easier to understand when looking at react aria examples, converting
code, etc. Not a great value adding this api in our tiny heads
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 50791c945d ♻️(home) move the join meeting dialog in its own component
feels less cluttered that way
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 1fb37f2f8e 💄(keyboard) tinker (again) with the global focus styles
- setting an invisible outline by default for better transitions
- excluding the select containers from keyboard focus rings
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier eab64b7197 💄(animations) add animations to smooth out general feeling
the app is your friend now, it's all smooth
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 786cd3e4c7 (forms) add a select field
via the <Field> component you can now describe a select input that
matches other field apis
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier c8f96b1f22 (frontend) new Form components
- improve the Form component to abstract the few things we'll certainly
do all the time (data parsing, action buttons rendering)
- add a Field component, the main way to render form fields. It mainly
wraps react aria components with our styling. The Checkbox component is
a bit tricky to go around some current limitations with react aria
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier df5f0dbf9f 💄(keyboard) improve the global focus ring selector
this triggered on a few things we didn't want (labels particularly).
plus, handle the focus ring color via panda, so that it's available in
the JS (will be useful in soon to be commited stuff)
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier dc67d7c190 🌐(frontend) replace all "conférence" by "réunion"
feels like we better understand with this term
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 82208c220a ♻️(frontend) tiny cleanup of the room regex related stuff
since this is used for the routing *and* for common validation, split a
few things
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 2e081e5e1e 🚸(frontend) prevent flash of "logged out" user content when loading
until now, we concluded that is `isLoggedIn` !== true meant the user
wasn't logged in. While it also meant that we are currently loading user
info.

wrap the whole in something that doesn't render anything until we made
the first user request to prevent this behavior.
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier 57b8a15642 💄(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
2024-07-25 14:46:00 +02:00
Emmanuel Pelletier b03bfe94a4 💚(gitlint) ignore missing body on release commits
release commits dont need any body info as the changelog ~~is~~ will be
updated accordingly to track changes of the release
2024-07-22 16:33:45 +02:00
Emmanuel Pelletier a2774eb888 📝(release) improve release doc after first release
explained a few things for the newbies like me, added a deployment
example, simplified the process while we are in "mvp" mode
2024-07-22 16:33:45 +02:00
97 changed files with 2259 additions and 457 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -72,7 +72,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -122,7 +122,7 @@ jobs:
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
+6 -9
View File
@@ -14,7 +14,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: show
@@ -77,9 +77,9 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install development dependencies
@@ -122,9 +122,6 @@ jobs:
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
@@ -145,7 +142,7 @@ jobs:
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
@@ -176,7 +173,7 @@ jobs:
repositories: "infrastructure,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
@@ -193,7 +190,7 @@ jobs:
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.10"
-3
View File
@@ -50,9 +50,6 @@ node_modules
# Mails
src/backend/core/templates/mail/
# Typescript client
src/frontend/tsclient
# Swagger
**/swagger.json
+6 -1
View File
@@ -62,12 +62,17 @@ words=wip
# For example, use the following regex if you only want to allow email addresses from foo.com
# regex=[^@]+@foo.com
[ignore-by-title]
[ignore-by-title:bots]
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
# upgrade dependencies
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
ignore=B6,UC1
[ignore-by-title:releases]
# Allow empty body for release commits
regex=^🔖.*$
ignore=B6
# [ignore-by-body]
# Ignore certain rules for commits of which the body has a line that matches a regex
# E.g. Match bodies that have a line that that contain "release"
-12
View File
@@ -49,7 +49,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn # FIXME : use npm
# -- Frontend
PATH_FRONT = ./src/frontend
@@ -275,17 +274,6 @@ mails-install: ## install the mail generator
@$(MAIL_YARN) install
.PHONY: mails-install
# -- TS client generator
# FIXME : adapt this command
tsclient-install: ## Install the Typescript API client generator
@$(TSCLIENT_YARN) install
.PHONY: tsclient-install
# FIXME : adapt this command
tsclient: tsclient-install ## Generate a Typescript API client
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
.PHONY: tsclient-install
# -- Misc
clean: ## restore repository state as it was freshly cloned
+50
View File
@@ -94,6 +94,56 @@ You first need to create a superuser account:
$ make superuser
```
### Run application on local Kubernetes
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
#### Getting Started
Make sure you have installed:
- kubectl
- helm
- helmfile
- tilt
To build and start the Kubernetes cluster using Kind:
```shell
$ make build-k8s-cluster
```
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
#### Debugging frontend
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
```yaml
...
docker_build(
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target='frontend-production', # Update this line when needed
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
...
```
## Contributing
This project is intended to be community-driven, so please, do not hesitate to
+50 -32
View File
@@ -1,39 +1,57 @@
# Releasing new version
# Releasing a new version
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number in the appropriate file, i.e. for backend in the `pyproject.toml` and `package.json` for node-based projects (tips: find and replace all occurrences of the previous version number to ensure consistency across files).
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations.
4. Update Docker image tag in Helm values files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with the latest Docker image tag.
```
5. Commit your changes with a structured message:
- Add a title including the version of the release and respecting the above described format using the 🔖 release emoji.
- Paste in the body all changes from the changelog concerned by this release, removing only the Markdown tags and making sure that lines are shorter than 74 characters.
1. Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend project, frontend projects, and Helm files:
```
- for backend, update the version number by hand in `pyproject.toml`,
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
```
The new images don't exist _yet_: they will be created automatically later in the process.
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
```text
🔖(minor) bump release to 4.18.0
Added:
- Implement base CLI commands (list, extract, fetch & push) for supported backends
- Support for ElasticSearch database backend
Changed:
- Replace LDP storage backend by FS storage backend`
```
6. Open a pull request.
7. Wait for an approval from your peers.
8. Merge your pull request.
9. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
10. Tag & push your commit: `git tag v4.18.1 && git push origin --tags`
11. Manually release your version on GitHub.
12. Ensure that the CI Docker job has successfully pushed the newly built Docker images to Docker Hub with the appropriate tags.
13. To deploy using ArgoCD, you need to update the `production` or `pre-production` tags. ArgoCD will automatically detect and deploy the new tag.
5. Open a pull request, wait for an approval from your peers and merge it.
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
7. Tag and push your commit:
```bash
git tag v4.18.1 && git push origin --tags
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
9. The release is now done!
# Deploying
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
Making a new release doesn't publish it automatically in production.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
-25
View File
@@ -1,25 +0,0 @@
# Api client TypeScript
The backend application can automatically create a TypeScript client to be used in frontend
applications. It is used in the Meet front application itself.
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
and Meet's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
## Requirements
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
install the backend application.
## Install openApiClientJs
```sh
$ cd src/tsclient
$ yarn install
```
## Generate the client
```sh
yarn generate:api:client:local <output_path_for_generated_client>
```
-3
View File
@@ -18,9 +18,6 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+44
View File
@@ -1,5 +1,6 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
@@ -11,6 +12,12 @@ from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
@@ -135,3 +142,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent loging flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent loging flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
@@ -15,7 +15,13 @@ import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@@ -229,3 +235,125 @@ def test_view_logout_callback():
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
@@ -157,7 +157,7 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
response = client.get("/api/v1.0/rooms/unregistered-room/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.json() == {"detail": "No Room matches the given query."}
@mock.patch("core.utils.generate_token", return_value="foo")
+5
View File
@@ -284,6 +284,8 @@ class Base(Configuration):
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "core.authentication.views.OIDCAuthenticationRequestView"
OIDC_CALLBACK_CLASS = "core.authentication.views.OIDCAuthenticationCallbackView"
OIDC_CREATE_USER = values.BooleanValue(
default=True,
environ_name="OIDC_CREATE_USER",
@@ -344,6 +346,9 @@ class Base(Configuration):
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
)
OIDC_REDIRECT_FIELD_NAME = values.Value(
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
)
# Video conference configuration
LIVEKIT_CONFIGURATION = {
+6 -6
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.1"
version = "0.1.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -19,7 +19,7 @@ classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
description = "An application to print markdown to pdf from a set of managed templates."
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
@@ -36,8 +36,8 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.3",
"djangorestframework==3.14.0",
"django==5.0.7",
"djangorestframework==3.15.2",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
"easy_thumbnails==2.8.5",
@@ -50,8 +50,8 @@ dependencies = [
"psycopg[binary]==3.1.14",
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.31.0",
"sentry-sdk==1.38.0",
"requests==2.32.2",
"sentry-sdk==2.8.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
+11 -2
View File
@@ -1,16 +1,17 @@
{
"name": "meet",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@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",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -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",
+33 -22
View File
@@ -35,18 +35,6 @@ const config: Config = {
exclude: [],
jsxFramework: 'react',
outdir: 'src/styled-system',
conditions: {
extend: {
// React Aria builds upon data attributes instead of css pseudo-classes, in case we style a React Aria component
// we dont want to trigger pseudo class related styles
'ra-hover': '&:is([data-hovered])',
'ra-focus': '&:is([data-focused])',
'ra-focusVisible': '&:is([data-focus-visible])',
'ra-disabled': '&:is([data-disabled])',
pressed: '&:is([data-pressed])',
'ra-pressed': '&:is([data-pressed])',
},
},
theme: {
...pandaPreset.theme,
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
@@ -58,6 +46,19 @@ const config: Config = {
xl: '80em', // 1280px
'2xl': '96em', // 1536px
},
keyframes: {
slide: {
from: {
transform: 'var(--origin)',
opacity: 0,
},
to: {
transform: 'translateY(0)',
opacity: 1,
},
},
fade: { from: { opacity: 0 }, to: { opacity: 1 } },
},
tokens: defineTokens({
/* we take a few things from the panda preset but for now we clear out some stuff.
* This way we'll only add the things we need step by step and prevent using lots of differents things.
@@ -165,7 +166,7 @@ const config: Config = {
colors: {
default: {
text: { value: '{colors.gray.900}' },
bg: { value: '{colors.slate.50}' },
bg: { value: 'white' },
subtle: { value: '{colors.gray.100}' },
'subtle-text': { value: '{colors.gray.600}' },
},
@@ -179,7 +180,8 @@ 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}' },
subtle: { value: '{colors.gray.400}' },
},
primary: {
DEFAULT: { value: '{colors.blue.700}' },
@@ -199,12 +201,13 @@ const config: Config = {
'subtle-text': { value: '{colors.red.700}' },
},
success: {
DEFAULT: { value: '{colors.emerald.700}' },
hover: { value: '{colors.emerald.800}' },
active: { value: '{colors.emerald.900}' },
DEFAULT: { value: '{colors.green.700}' },
hover: { value: '{colors.green.800}' },
active: { value: '{colors.green.900}' },
text: { value: '{colors.white}' },
subtle: { value: '{colors.emerald.100}' },
'subtle-text': { value: '{colors.emerald.700}' },
subtle: { value: '{colors.green.100}' },
'subtle-text': { value: '{colors.green.800}' },
...pandaPreset.theme.tokens.colors.green,
},
warning: {
DEFAULT: { value: '{colors.amber.700}' },
@@ -214,6 +217,7 @@ const config: Config = {
subtle: { value: '{colors.amber.100}' },
'subtle-text': { value: '{colors.amber.700}' },
},
focusRing: { value: 'rgb(74, 121, 199)' },
},
shadows: {
box: { value: '{shadows.sm}' },
@@ -229,12 +233,20 @@ const config: Config = {
DEFAULT: { value: '{spacing.1}' },
lg: { value: '{spacing.2}' },
},
paragraph: { value: '{spacing.1}' },
paragraph: { value: '{spacing.0.5}' },
heading: { value: '{spacing.1}' },
gutter: { value: '{spacing.1}' },
textfield: { value: '{spacing.1}' },
},
}),
textStyles: defineTextStyles({
display: {
value: {
fontSize: '3rem',
lineHeight: '2rem',
fontWeight: 700,
},
},
h1: {
value: {
fontSize: '1.5rem',
@@ -253,7 +265,6 @@ const config: Config = {
value: {
fontSize: '1.125rem',
lineHeight: '1.75rem',
fontWeight: 700,
},
},
body: {
@@ -262,7 +273,7 @@ const config: Config = {
lineHeight: '1.5',
},
},
small: {
sm: {
value: {
fontSize: '0.875rem',
lineHeight: '1.25rem',
+13 -11
View File
@@ -5,11 +5,10 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { Route, Switch } from 'wouter'
import { Screen } from './layout/Screen'
import { HomeRoute } from '@/features/home'
import { RoomRoute, roomIdRegex } from '@/features/rooms'
import { NotFound } from './routes/NotFound'
import { Switch, Route } from 'wouter'
import { NotFoundScreen } from './layout/NotFoundScreen'
import { RenderIfUserFetched } from './features/auth'
import { routes } from './routes'
import './i18n/init'
const queryClient = new QueryClient()
@@ -19,12 +18,15 @@ function App() {
useLang(i18n.language)
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<Screen />}>
<Switch>
<Route path="/" component={HomeRoute} />
<Route path={roomIdRegex} component={RoomRoute} />
<Route component={NotFound} />
</Switch>
<Suspense fallback={null}>
<RenderIfUserFetched>
<Switch>
{Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} />
))}
<Route component={NotFoundScreen} />
</Switch>
</RenderIfUserFetched>
<ReactQueryDevtools initialIsOpen={false} />
</Suspense>
</QueryClientProvider>
+25
View File
@@ -0,0 +1,25 @@
import { Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { RiExternalLinkLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export const Feedback = () => {
const { t } = useTranslation()
return (
<Button
href="https://grist.incubateur.net/o/docs/forms/1YrfNP1QSSy8p2gCxMFnSf/4"
variant="success"
target="_blank"
>
<span className={css({ marginRight: 0.5 })} aria-hidden="true">
💡
</span>
{t('feedbackAlert')}
<RiExternalLinkLine
size={16}
className={css({ marginLeft: 0.5 })}
aria-hidden="true"
/>
</Button>
)
}
@@ -1,6 +1,7 @@
import { ApiError } from '@/api/ApiError'
import { fetchApi } from '@/api/fetchApi'
import { type ApiUser } from './ApiUser'
import { attemptSilentLogin, canAttemptSilentLogin } from '../utils/silentLogin'
/**
* fetch the logged-in user from the api.
@@ -16,7 +17,13 @@ export const fetchUser = (): Promise<ApiUser | false> => {
.catch((error) => {
// we assume that a 401 means the user is not logged in
if (error instanceof ApiError && error.statusCode === 401) {
resolve(false)
// make sure to not resolve the promise while trying to silent login
// so that consumers of fetchUser don't think the work already ended
if (canAttemptSilentLogin()) {
attemptSilentLogin(3600)
} else {
resolve(false)
}
} else {
reject(error)
}
+12 -3
View File
@@ -1,17 +1,26 @@
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { fetchUser } from './fetchUser'
import { type ApiUser } from './ApiUser'
/**
* returns info about currently logged in user
*
* `isLoggedIn` is undefined while query is loading and true/false when it's done
*/
export const useUser = () => {
const query = useQuery({
queryKey: [keys.user],
queryFn: fetchUser,
})
const isLoggedIn =
query.status === 'success' ? query.data !== false : undefined
const isLoggedOut = isLoggedIn === false
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,
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
isLoggedIn,
}
}
@@ -0,0 +1,17 @@
import { type ReactNode } from 'react'
import { useUser } from '@/features/auth'
import { LoadingScreen } from '@/layout/LoadingScreen'
/**
* wrapper that renders children only when user info has been actually fetched
*
* this is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
*/
export const RenderIfUserFetched = ({ children }: { children: ReactNode }) => {
const { isLoggedIn } = useUser()
return isLoggedIn !== undefined ? (
children
) : (
<LoadingScreen renderTimeout={1000} />
)
}
+1
View File
@@ -1,3 +1,4 @@
export { useUser } from './api/useUser'
export { authUrl } from './utils/authUrl'
export { logoutUrl } from './utils/logoutUrl'
export { RenderIfUserFetched } from './components/RenderIfUserFetched'
@@ -1,5 +1,8 @@
import { apiUrl } from '@/api/apiUrl'
export const authUrl = () => {
return apiUrl('/authenticate')
export const authUrl = ({
silent = false,
returnTo = window.location.href,
} = {}) => {
return apiUrl(`/authenticate?silent=${encodeURIComponent(silent)}&returnTo=${encodeURIComponent(returnTo)}`)
}
@@ -0,0 +1,34 @@
import { authUrl } from "@/features/auth";
const SILENT_LOGIN_RETRY_KEY = 'silent-login-retry'
const isRetryAllowed = () => {
const lastRetryDate = localStorage.getItem(SILENT_LOGIN_RETRY_KEY);
if (!lastRetryDate) {
return true;
}
const now = new Date();
return now.getTime() > Number(lastRetryDate)
}
const setNextRetryTime = (retryIntervalInSeconds: number) => {
const now = new Date()
const nextRetryTime = now.getTime() + (retryIntervalInSeconds * 1000);
localStorage.setItem(SILENT_LOGIN_RETRY_KEY, String(nextRetryTime));
}
const initiateSilentLogin = () => {
window.location.href = authUrl({ silent: true })
}
export const canAttemptSilentLogin = () => {
return isRetryAllowed()
}
export const attemptSilentLogin = (retryIntervalInSeconds: number) => {
if (!isRetryAllowed()) {
return
}
setNextRetryTime(retryIntervalInSeconds)
initiateSilentLogin()
}
@@ -0,0 +1,45 @@
import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms'
export const JoinMeetingDialog = () => {
const { t } = useTranslation('home')
return (
<Dialog title={t('joinMeeting')}>
<Form
onSubmit={(data) => {
navigateTo(
'room',
(data.roomId as string)
.trim()
.replace(`${window.location.origin}/`, '')
)
}}
submitLabel={t('joinInputSubmit')}
>
<Field
type="text"
name="roomId"
label={t('joinInputLabel')}
description={t('joinInputExample', {
example: 'https://meet.numerique.gouv.fr/azer-tyu-qsdf',
})}
validate={(value) => {
return !isRoomValid(value.trim()) ? (
<>
<p>{t('joinInputError')}</p>
<Ul>
<li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li>
</Ul>
</>
) : null
}}
/>
</Form>
<H lvl={2}>{t('joinMeetingTipHeading')}</H>
<P last>{t('joinMeetingTipContent')}</P>
</Dialog>
)
}
-1
View File
@@ -1,2 +1 @@
export { navigateToHome } from './navigation/navigateToHome'
export { Home as HomeRoute } from './routes/Home'
@@ -1,5 +0,0 @@
import { navigate } from 'wouter/use-browser-location'
export const navigateToHome = () => {
navigate(`/`)
}
+42 -23
View File
@@ -1,37 +1,56 @@
import { useTranslation } from 'react-i18next'
import { A, Button, Italic, P, Div, H, Box } from '@/primitives'
import { DialogTrigger } from 'react-aria-components'
import { Button, Div, Text, VerticallyOffCenter } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo'
import { generateRoomId } from '@/features/rooms'
import { authUrl, useUser } from '@/features/auth'
import { navigateToNewRoom } from '@/features/rooms'
import { Screen } from '@/layout/Screen'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
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')}
<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
? () =>
navigateTo('room', generateRoomId(), {
state: { create: true },
})
: undefined
}
href={isLoggedIn ? undefined : authUrl()}
>
{isLoggedIn ? t('createMeeting') : t('login', { ns: 'global' })}
</Button>
<DialogTrigger>
<Button variant="primary" outline>
{t('joinMeeting')}
</Button>
) : (
<p>
<A href={authUrl()}>{t('login')}</A>
</p>
)}
</Box>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Div>
<P>
<Italic>{t('or')}</Italic>
</P>
<Box variant="subtle" size="sm">
<p>{t('copyMeetingUrl')}</p>
</Box>
</Box>
</VerticallyOffCenter>
</Screen>
)
}
@@ -1,20 +1,25 @@
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LiveKitRoom,
VideoConference,
type LocalUserChoices,
} from '@livekit/components-react'
import { Room, RoomOptions } from 'livekit-client'
import { keys } from '@/api/queryKeys'
import { navigateTo } from '@/navigation/navigateTo'
import { QueryAware } from '@/layout/QueryAware'
import { navigateToHome } from '@/features/home'
import { fetchRoom } from '../api/fetchRoom'
import { InviteDialog } from './InviteDialog'
export const Conference = ({
roomId,
userConfig,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
}) => {
const { status, data } = useQuery({
queryKey: [keys.room, roomId, userConfig.username],
@@ -25,23 +30,44 @@ export const Conference = ({
}),
})
const roomOptions = useMemo((): RoomOptions => {
return {
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
},
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
return (
<QueryAware status={status}>
<LiveKitRoom
room={room}
serverUrl={data?.livekit?.url}
token={data?.livekit?.token}
connect={true}
audio={{
deviceId: userConfig.audioDeviceId,
}}
video={{
deviceId: userConfig.videoDeviceId,
}}
audio={userConfig.audioEnabled}
video={userConfig.videoEnabled}
onDisconnected={() => {
navigateToHome()
navigateTo('feedback')
}}
>
<VideoConference />
{showInviteDialog && (
<InviteDialog
isOpen={showInviteDialog}
onOpenChange={setShowInviteDialog}
roomId={roomId}
onClose={() => setShowInviteDialog(false)}
/>
)}
</LiveKitRoom>
</QueryAware>
)
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRouteUrl } from '@/navigation/getRouteUrl'
import { Div, Button, Dialog, Input, type DialogProps } from '@/primitives'
import { HStack } from '@/styled-system/jsx'
export const InviteDialog = ({
roomId,
...dialogProps
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms')
const roomUrl = getRouteUrl('room', roomId)
const copyLabel = t('shareDialog.copy')
const copiedLabel = t('shareDialog.copied')
const [copyLinkLabel, setCopyLinkLabel] = useState(copyLabel)
useEffect(() => {
if (copyLinkLabel == copiedLabel) {
const timeout = setTimeout(() => {
setCopyLinkLabel(copyLabel)
}, 5000)
return () => {
clearTimeout(timeout)
}
}
}, [copyLinkLabel, copyLabel, copiedLabel])
return (
<Dialog {...dialogProps} title={t('shareDialog.heading')}>
<HStack alignItems="stretch" gap="gutter">
<Div flex="1">
<Input
type="text"
aria-label={t('shareDialog.inputLabel')}
value={roomUrl}
readOnly
onClick={(e) => {
e.currentTarget.select()
}}
/>
</Div>
<Div minWidth="8rem">
<Button
variant="primary"
size="sm"
fullWidth
onPress={() => {
navigator.clipboard.writeText(roomUrl)
setCopyLinkLabel(copiedLabel)
}}
>
{copyLinkLabel}
</Button>
</Div>
</HStack>
</Dialog>
)
}
@@ -11,7 +11,14 @@ export const Join = ({
return (
<Box title={t('join.heading')} withBackButton>
<PreJoin persistUserChoices onSubmit={onSubmit} />
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</Box>
)
}
+3 -2
View File
@@ -1,3 +1,4 @@
export { navigateToNewRoom } from './navigation/navigateToNewRoom'
export { Room as RoomRoute } from './routes/Room'
export { roomIdRegex } from './utils/generateRoomId'
export { FeedbackRoute } from './routes/Feedback'
export { roomIdPattern, isRoomValid } from './utils/isRoomValid'
export { generateRoomId } from './utils/generateRoomId'
@@ -1,6 +0,0 @@
import { navigate } from 'wouter/use-browser-location'
import { generateRoomId } from '../utils/generateRoomId'
export const navigateToNewRoom = () => {
navigate(`/${generateRoomId()}`)
}
@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from '@/layout/BoxScreen'
import { Div, Link, P } from '@/primitives'
export const FeedbackRoute = () => {
const { t } = useTranslation('rooms')
return (
<BoxScreen title={t('feedback.heading')}>
<Div textAlign="left">
<P>{t('feedback.body')}</P>
</Div>
<Div marginTop={1}>
<P>
<Link to="/">{t('backToHome', { ns: 'global' })}</Link>
</P>
</Div>
</BoxScreen>
)
}
+31 -10
View File
@@ -1,24 +1,45 @@
import { type LocalUserChoices } from '@livekit/components-react'
import { useState } from 'react'
import {
usePersistentUserChoices,
type LocalUserChoices,
} from '@livekit/components-react'
import { useParams } from 'wouter'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Screen } from '@/layout/Screen'
import { ErrorScreen } from '@/layout/ErrorScreen'
import { useUser } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
export const Room = () => {
const { user, isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
const { roomId } = useParams()
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
if (!roomId) {
return <ErrorScreen />
}
return (
<Screen>
{userConfig ? (
<Conference roomId={roomId} userConfig={userConfig} />
) : (
if (!userConfig && !skipJoinScreen) {
return (
<Screen>
<Join onSubmit={setUserConfig} />
)}
</Screen>
</Screen>
)
}
return (
<Conference
roomId={roomId}
mode={mode}
userConfig={{
...existingUserChoices,
...(skipJoinScreen ? { username: user?.email as string } : {}),
...userConfig,
}}
/>
)
}
@@ -12,5 +12,3 @@ const generateSegment = (length: number): string =>
// Generates a unique room identifier following the Google Meet format
export const generateRoomId = () =>
[generateSegment(3), generateSegment(4), generateSegment(3)].join('-')
export const roomIdRegex = /^[/](?<roomId>[a-z]{3}-[a-z]{4}-[a-z]{3})$/
@@ -0,0 +1,5 @@
export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
export const isRoomValid = (roomIdOrUrl: string) =>
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { DialogTrigger } from 'react-aria-components'
import { RiSettings3Line } from '@remixicon/react'
import { Button } from '@/primitives'
import { SettingsDialog } from './SettingsDialog'
export const SettingsButton = () => {
const { t } = useTranslation('settings')
return (
<DialogTrigger>
<Button
square
invisible
aria-label={t('settingsButtonLabel')}
tooltip={t('settingsButtonLabel')}
>
<RiSettings3Line />
</Button>
<SettingsDialog />
</DialogTrigger>
)
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { Dialog, Field, H } from '@/primitives'
export const SettingsDialog = () => {
const { t, i18n } = useTranslation('settings')
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Dialog title={t('dialog.heading')}>
<H lvl={2}>{t('language.heading')}</H>
<Field
type="select"
label={t('language.label')}
items={languagesList}
defaultSelectedKey={currentLanguage.key}
onSelectionChange={(lang) => {
i18n.changeLanguage(lang as string)
}}
/>
</Dialog>
)
}
@@ -0,0 +1,2 @@
export { SettingsButton } from './components/SettingsButton'
export { SettingsDialog } from './components/SettingsDialog'
+1 -1
View File
@@ -9,7 +9,7 @@ export const LanguageSelector = () => {
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage,
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
+7 -1
View File
@@ -16,5 +16,11 @@ export const useLanguageLabels = () => {
value: lang,
label: langageLabels[lang],
}))
return { languagesList, currentLanguage: langageLabels[i18n.language] }
return {
languagesList,
currentLanguage: {
key: i18n.language,
label: langageLabels[i18n.language],
},
}
}
+14 -12
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>
)
}
+64 -21
View File
@@ -1,49 +1,92 @@
import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { flex } from '@/styled-system/patterns'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { LanguageSelector } from '@/i18n/LanguageSelector'
import { A, Badge, Text } from '@/primitives'
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
import { SettingsButton } from '@/features/settings'
import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
import { Feedback } from '@/components/Feedback'
export const Header = () => {
const { t } = useTranslation()
const isHome = useMatchesRoute('home')
const isRoom = useMatchesRoute('room')
const { user, isLoggedIn } = useUser()
return (
<div
className={css({
backgroundColor: 'primary.text',
color: 'primary',
borderBottomColor: 'box.border',
borderBottomWidth: 1,
borderBottomStyle: 'solid',
padding: 1,
paddingY: 1,
paddingX: 1,
flexShrink: 0,
boxShadow: 'box',
})}
>
<Stack direction="row" justify="space-between" align="center">
<div
className={css({
display: 'flex',
flexDirection: 'column',
rowGap: 1,
md: {
rowGap: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
})}
>
<header>
<Text bold variant="h1" margin={false}>
<Link to="/">{t('app')}</Link>
</Text>
<Stack gap={2.25} direction="row" align="center">
<Text bold variant="h1" margin={false}>
<Link
onClick={(event) => {
if (
isRoom &&
!window.confirm(t('leaveRoomPrompt', { ns: 'rooms' }))
) {
event.preventDefault()
}
}}
to="/"
>
{t('app')}
</Link>
</Text>
<Feedback />
</Stack>
</header>
<nav>
<Stack gap={1} direction="row" align="center">
<LanguageSelector />
{isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>}
{!!user && (
<p className={flex({ gap: 1, align: 'center' })}>
<Badge>{user.email}</Badge>
<A href={logoutUrl()} size="small">
{t('logout')}
</A>
</p>
{isLoggedIn === false && !isHome && (
<A href={authUrl()}>{t('login')}</A>
)}
{!!user && (
<Popover aria-label={t('logout')}>
<Button
size="sm"
invisible
tooltip={t('loggedInUserTooltip')}
tooltipType="delayed"
>
{user.email}
</Button>
<PopoverList
items={[{ value: 'logout', label: t('logout') }]}
onAction={(value) => {
if (value === 'logout') {
window.location.href = logoutUrl()
}
}}
/>
</Popover>
)}
<SettingsButton />
</Stack>
</nav>
</Stack>
</div>
</div>
)
}
+16 -4
View File
@@ -2,22 +2,34 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
import { Screen } from './Screen'
import { VerticallyOffCenter } from '@/primitives'
import { Center } from '@/styled-system/jsx'
export const LoadingScreen = ({ asBox = false }: { asBox?: boolean }) => {
export const LoadingScreen = ({
asBox = false,
renderTimeout = 500,
}: {
asBox?: boolean
renderTimeout?: number
}) => {
const { t } = useTranslation()
// show the loading screen only after a little while to prevent flash of texts
const [show, setShow] = useState(false)
useEffect(() => {
const timeout = setTimeout(() => setShow(true), 500)
const timeout = setTimeout(() => setShow(true), renderTimeout)
return () => clearTimeout(timeout)
}, [])
}, [renderTimeout])
if (!show) {
return null
}
const Container = asBox ? BoxScreen : Screen
return (
<Container>
<p>{t('loading')}</p>
<VerticallyOffCenter>
<Center>
<p>{t('loading')}</p>
</Center>
</VerticallyOffCenter>
</Container>
)
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { ErrorScreen } from './ErrorScreen'
import { LoadingScreen } from './LoadingScreen'
import { Screen } from './Screen'
export const QueryAware = ({
status,
@@ -16,5 +17,5 @@ export const QueryAware = ({
return <LoadingScreen />
}
return children
return <Screen>{children}</Screen>
}
+11 -3
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}
+4
View File
@@ -1,9 +1,12 @@
{
"app": "Meet",
"backToHome": "",
"cancel": "",
"closeDialog": "",
"error": {
"heading": ""
},
"feedbackAlert": "",
"forbidden": {
"heading": ""
},
@@ -12,6 +15,7 @@
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
"logout": "",
"notFound": {
+8 -3
View File
@@ -1,8 +1,13 @@
{
"copyMeetingUrl": "",
"createMeeting": "",
"heading": "",
"intro": "",
"login": "",
"or": ""
"joinInputError": "",
"joinInputExample": "",
"joinInputLabel": "",
"joinInputSubmit": "",
"joinMeeting": "",
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": ""
}
+16 -1
View File
@@ -1,5 +1,20 @@
{
"join": {
"feedback": {
"body": "",
"heading": ""
},
"join": {
"camlabel": "",
"heading": "",
"joinLabel": "",
"micLabel": "",
"userLabel": ""
},
"leaveRoomPrompt": "",
"shareDialog": {
"copied": "",
"copy": "",
"heading": "",
"inputLabel": ""
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"dialog": {
"heading": ""
},
"language": {
"heading": "",
"label": ""
},
"settingsButtonLabel": ""
}
+5 -1
View File
@@ -1,9 +1,12 @@
{
"app": "Meet",
"backToHome": "Back to homescreen",
"cancel": "Cancel",
"closeDialog": "Close dialog",
"error": {
"heading": "An error occured while loading the page"
},
"feedbackAlert": "Give us feedback",
"forbidden": {
"heading": "You don't have the permission to view this page"
},
@@ -12,9 +15,10 @@
"popoverLabel": "Choose language"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": "Login",
"logout": "Logout",
"notFound": {
"heading": ""
"heading": "Page not found"
}
}
+10 -5
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 code. Examples:",
"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"
}
+16 -1
View File
@@ -1,5 +1,20 @@
{
"feedback": {
"body": "Please fill out the form available in the header to give us your precious feedback! Thanks.",
"heading": "Help us improve Meet"
},
"join": {
"heading": "Verify your settings before joining"
"camlabel": "Camera",
"heading": "Join the meeting",
"joinLabel": "Join",
"micLabel": "Microphone",
"userLabel": "Your name"
},
"leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": {
"copied": "Copied",
"copy": "Copy",
"heading": "Share the meeting link",
"inputLabel": "Meeting link"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"dialog": {
"heading": "Settings"
},
"language": {
"heading": "Language",
"label": "Language"
},
"settingsButtonLabel": "Settings"
}
+4
View File
@@ -1,9 +1,12 @@
{
"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"
},
"feedbackAlert": "Donnez-nous votre avis",
"forbidden": {
"heading": "Accès interdit"
},
@@ -12,6 +15,7 @@
"popoverLabel": "Choix de la langue"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": "Se connecter",
"logout": "Se déconnecter",
"notFound": {
+11 -6
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"
"createMeeting": "Créer une réunion",
"heading": "Meet",
"intro": "Collaborez en toute simplicité, où que vous soyez.",
"joinInputError": "Saisissez un lien ou un code de réunion. Exemples :",
"joinInputExample": "Un code de réunion ressemble à ceci : abc-defg-hij",
"joinInputLabel": "Lien complet ou code de la réunion",
"joinInputSubmit": "Rejoindre la réunion",
"joinMeeting": "Rejoindre une réunion",
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
"joinMeetingTipHeading": "Astuce",
"loginToCreateMeeting": "Connectez-vous pour créer une réunion"
}
+16 -1
View File
@@ -1,5 +1,20 @@
{
"feedback": {
"body": "Remplissez le formulaire disponible dans l'entête du site pour nous donner votre avis sur l'outil. Vos retours sont précieux ! Merci.",
"heading": "Aidez-nous à améliorer Meet"
},
"join": {
"heading": "Vérifiez vos paramètres"
"camlabel": "Webcam",
"heading": "Rejoindre la réunion",
"joinLabel": "Rejoindre",
"micLabel": "Micro",
"userLabel": "Votre nom"
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": {
"copied": "Lien copié",
"copy": "Copier le lien",
"heading": "Partager le lien vers la réunion",
"inputLabel": "Lien vers la réunion"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"dialog": {
"heading": "Paramètres"
},
"language": {
"heading": "Langue",
"label": "Langue de l'application"
},
"settingsButtonLabel": "Paramètres"
}
@@ -0,0 +1,9 @@
import { type RouteName, routes } from '@/routes'
export const getRouteByName = (routeName: RouteName) => {
const route = routes[routeName]
if (!route) {
throw new Error(`Route "${routeName}" does not exist`)
}
return route
}
@@ -0,0 +1,19 @@
import { RouteName } from '@/routes'
import { getRouteByName } from './getRouteByName'
export const getRoutePath = (
routeName: RouteName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
) => {
const route = getRouteByName(routeName)
const to = route.to
? route.to(params)
: typeof route.path === 'string'
? route.path
: null
if (!to) {
throw new Error(`Can't find path to navigate to for ${routeName}`)
}
return to
}
@@ -0,0 +1,11 @@
import { RouteName } from '@/routes'
import { getRoutePath } from './getRoutePath'
export const getRouteUrl = (
routeName: RouteName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any
) => {
const to = getRoutePath(routeName, params)
return `${window.location.origin}${to}`
}
+21
View File
@@ -0,0 +1,21 @@
import { RouteName } from '@/routes'
import { navigate } from 'wouter/use-browser-location'
import { getRouteByName } from './getRouteByName'
export const navigateTo = <S = unknown>(
routeName: RouteName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
params?: any,
options?: { replace?: boolean; state?: S }
) => {
const route = getRouteByName(routeName)
const to = route.to
? route.to(params)
: typeof route.path === 'string'
? route.path
: null
if (!to) {
throw new Error(`Can't find path to navigate to for ${routeName}`)
}
return navigate(to, options)
}
@@ -0,0 +1,7 @@
import { useRoute } from 'wouter'
import { type RouteName, routes } from '../routes'
export const useMatchesRoute = (route: RouteName) => {
const [match] = useRoute(routes[route].path)
return match
}
+5 -4
View File
@@ -7,17 +7,18 @@ const link = cva({
textUnderlineOffset: '2',
cursor: 'pointer',
borderRadius: 2,
'_ra-hover': {
transition: 'all 0.2s',
'&[data-hovered]': {
textDecoration: 'none',
},
'_ra-pressed': {
'&[data-pressed]': {
textDecoration: 'underline',
},
},
variants: {
size: {
small: {
textStyle: 'small',
sm: {
textStyle: 'sm',
},
},
},
+1 -1
View File
@@ -10,7 +10,7 @@ const badge = cva({
},
variants: {
size: {
small: {
sm: {
textStyle: 'badge',
},
normal: {},
+13 -2
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,12 +15,18 @@ const box = cva({
margin: 'auto',
width: '38rem',
maxWidth: '100%',
marginTop: '6rem',
textAlign: 'center',
borderColor: 'transparent',
paddingY: 0,
},
popover: {
padding: 'boxPadding.xs',
minWidth: '10rem',
boxShadow: '0 8px 20px #0000001a',
},
dialog: {
width: '30rem',
maxWidth: '100%',
},
},
variant: {
@@ -29,12 +36,16 @@ const box = cva({
borderColor: 'box.border',
backgroundColor: 'box.bg',
color: 'box.text',
boxShadow: 'box',
},
subtle: {
color: 'default.subtle-text',
backgroundColor: 'default.subtle',
},
control: {
border: '1px solid {colors.control.border}',
backgroundColor: 'box.bg',
color: 'control.text',
},
},
size: {
default: {
+95 -14
View File
@@ -1,23 +1,28 @@
import { type ReactNode } from 'react'
import {
Button as RACButton,
type ButtonProps as RACButtonsProps,
TooltipTrigger,
Link,
LinkProps,
} from 'react-aria-components'
import { cva, type RecipeVariantProps } from '@/styled-system/css'
import { Tooltip } from './Tooltip'
const button = cva({
base: {
display: 'inline-block',
transition: 'background 200ms',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'background 200ms, outline 200ms, border-color 200ms',
cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'_ra-hover': {
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'_ra-pressed': {
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
},
@@ -27,11 +32,23 @@ const button = cva({
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
'--square-padding': '{spacing.0.625}',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
'--square-padding': '{spacing.0.25}',
},
xs: {
borderRadius: 4,
'--square-padding': '0',
},
},
square: {
true: {
paddingX: 'var(--square-padding)',
paddingY: 'var(--square-padding)',
},
},
variant: {
@@ -41,20 +58,51 @@ const button = cva({
primary: {
colorPalette: 'primary',
},
// @TODO: better handling of colors… this is a mess
success: {
colorPalette: 'success',
borderColor: 'success.300',
color: 'success.subtle-text',
backgroundColor: 'success.subtle',
'&[data-hovered]': {
backgroundColor: 'success.200',
},
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'_ra-hover': {
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'_ra-pressed': {
'&[data-pressed]': {
backgroundColor: 'colorPalette.subtle!',
},
},
},
invisible: {
true: {
borderColor: 'none!',
backgroundColor: 'none!',
'&[data-hovered]': {
backgroundColor: 'none!',
borderColor: 'colorPalette.active!',
},
'&[data-pressed]': {
borderColor: 'currentcolor',
},
},
},
fullWidth: {
true: {
width: 'full',
},
},
},
defaultVariants: {
size: 'default',
@@ -63,21 +111,54 @@ const button = cva({
},
})
export type ButtonProps = RecipeVariantProps<typeof button> & RACButtonsProps
type Tooltip = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
export type ButtonProps = RecipeVariantProps<typeof button> &
RACButtonsProps &
Tooltip
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
export const Button = (props: ButtonOrLinkProps) => {
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
const [variantProps, componentProps] = button.splitVariantProps(props)
if ((props as LinkButtonProps).href !== undefined) {
return <Link className={button(variantProps)} {...componentProps} />
return (
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<Link className={button(variantProps)} {...componentProps} />
</TooltipWrapper>
)
}
return (
<RACButton
className={button(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
<TooltipWrapper tooltip={tooltip} tooltipType={tooltipType}>
<RACButton
className={button(variantProps)}
{...(componentProps as RACButtonsProps)}
/>
</TooltipWrapper>
)
}
const TooltipWrapper = ({
tooltip,
tooltipType,
children,
}: {
children: ReactNode
} & Tooltip) => {
return tooltip ? (
<TooltipTrigger delay={tooltipType === 'instant' ? 300 : 1000}>
{children}
<Tooltip>{tooltip}</Tooltip>
</TooltipTrigger>
) : (
children
)
}
+170
View File
@@ -0,0 +1,170 @@
import { type ReactNode, useId, useState } from 'react'
import {
type CheckboxProps as RACCheckboxProps,
Checkbox as RACCheckbox,
CheckboxContext,
} from 'react-aria-components'
import { type StyledVariantProps } from '@/styled-system/types'
import { styled } from '@/styled-system/jsx'
import { FieldErrors } from './FieldErrors'
import { FieldDescription } from './FieldDescription'
// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html
export const StyledCheckbox = styled(RACCheckbox, {
base: {
display: 'flex',
alignItems: 'center',
gap: 0.375,
forcedColorAdjust: 'none',
width: 'fit-content',
'& .mt-Checkbox-checkbox': {
borderColor: 'control.border',
flexShrink: 0,
width: '1.375rem',
height: '1.375rem',
border: '1px solid',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 200ms',
},
'& svg': {
stroke: 'primary.text',
width: '0.875rem',
height: '0.875rem',
flexShrink: 0,
fill: 'none',
strokeWidth: '3px',
strokeDasharray: '22px',
strokeDashoffset: '66',
transition: 'all 200ms',
},
'&[data-pressed] .mt-Checkbox-checkbox': {
borderColor: 'focusRing',
},
'&[data-focus-visible] .mt-Checkbox-checkbox': {
outline: '2px solid!',
outlineColor: 'focusRing!',
outlineOffset: '2px!',
},
'&[data-selected] .mt-Checkbox-checkbox': {
borderColor: 'primary',
backgroundColor: 'primary',
},
'&[data-selected][data-pressed] .mt-Checkbox-checkbox': {
borderColor: 'primary.active',
backgroundColor: 'primary.active',
},
'&[data-selected] svg': {
strokeDashoffset: '44',
},
'&[data-mt-checkbox-invalid="true"] .mt-Checkbox-checkbox': {
borderColor: 'danger',
},
'&[data-selected][data-mt-checkbox-invalid="true"] .mt-Checkbox-checkbox': {
backgroundColor: 'danger',
},
},
variants: {
size: {
sm: {
base: {},
'& .mt-Checkbox-checkbox': {
width: '1.125rem',
height: '1.125rem',
},
'& svg': {
width: '0.625rem',
height: '0.625rem',
},
},
},
},
})
export type CheckboxProps = StyledVariantProps<typeof StyledCheckbox> &
RACCheckboxProps & { description?: ReactNode }
/**
* RAC Checkbox wrapper that adds support for description/error messages
*
* This is because description and error messages are not supported for
* standalone checkboxes (see https://github.com/adobe/react-spectrum/issues/6192)
*
* We do some trickery to go around a few things so that we can trigger
* the error message with the `validate` prop like other fields.
*
* Used internally by checkbox fields and checkbox group fields.
*
* note: this could be split in two components, one to render dumb, styled checkboxes,
* like Input or Radio, and another to behave as an actual "CheckboxField", that
* handles the error and description messages. No need for now though!
*/
export const Checkbox = ({
isInvalid,
description,
children,
...props
}: CheckboxProps) => {
const [error, setError] = useState<ReactNode | null>(null)
const errorId = useId()
const descriptionId = useId()
if (isInvalid !== undefined) {
console.error(
'Checkbox: passing isInvalid is not supported, use the validate prop instead'
)
return null
}
return (
<div>
<CheckboxContext.Provider
value={{
'aria-describedby': [
!!description && descriptionId,
!!error && errorId,
]
.filter(Boolean)
.join(' '),
// @ts-expect-error Any html attribute is actually valid
'data-mt-checkbox-invalid': !!error,
}}
>
<StyledCheckbox {...props}>
{(renderProps) => {
renderProps.isInvalid && !!props.validate
? setError(props.validate(renderProps.isSelected))
: setError(null)
return (
<>
<div className="mt-Checkbox-checkbox">
<svg
width={18}
height={18}
viewBox="0 0 18 18"
aria-hidden="true"
preserveAspectRatio="xMinYMin meet"
>
<polyline points="1 9 7 14 15 4" />
</svg>
</div>
<div>
{typeof children === 'function'
? children(renderProps)
: children}
</div>
</>
)
}}
</StyledCheckbox>
</CheckboxContext.Provider>
{!!description && (
<FieldDescription id={descriptionId}>{description}</FieldDescription>
)}
{!!error && <FieldErrors id={errorId} errors={[error]} />}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { styled } from '@/styled-system/jsx'
import { RiCloseLine } from '@remixicon/react'
import { t } from 'i18next'
import {
Dialog as RACDialog,
ModalOverlay,
Modal,
type DialogProps as RACDialogProps,
Heading,
} from 'react-aria-components'
import { Div, Button, Box, VerticallyOffCenter } from '@/primitives'
import { text } from './Text'
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,
'&[data-entering]': { animation: 'fade 200ms' },
'&[data-exiting]': { animation: 'fade 150ms reverse ease-in' },
},
})
// 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',
'--origin': 'translateY(32px)',
'&[data-entering]': { animation: 'slide 300ms' },
},
})
const StyledRACDialog = styled(RACDialog, {
base: {
width: 'full',
height: 'full',
pointerEvents: 'none',
},
})
export type DialogProps = RACDialogProps & {
title: string
onClose?: () => void
/**
* use the Dialog as a controlled component
*/
isOpen?: boolean
/**
* use the Dialog as a controlled component:
* this is called when isOpen should be updated
* after user interaction
*/
onOpenChange?: (isOpen: boolean) => void
}
export const Dialog = ({
title,
children,
onClose,
isOpen,
onOpenChange,
...dialogProps
}: DialogProps) => {
const isAlert = dialogProps['role'] === 'alertdialog'
return (
<StyledModalOverlay
isKeyboardDismissDisabled={isAlert}
isDismissable={!isAlert}
isOpen={isOpen}
onOpenChange={(isOpen) => {
if (onOpenChange) {
onOpenChange(isOpen)
}
if (!isOpen && onClose) {
onClose()
}
}}
>
<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 children === 'function'
? children({ close })
: children}
{!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>
)
}
+212
View File
@@ -0,0 +1,212 @@
import { styled } from '@/styled-system/jsx'
import { type ReactNode } from 'react'
import {
Label,
TextField as RACTextField,
FieldError as RACFieldError,
CheckboxGroup,
RadioGroup,
type TextFieldProps,
type CheckboxProps,
type CheckboxGroupProps,
type RadioGroupProps,
type SelectProps,
} from 'react-aria-components'
import { FieldDescription } from './FieldDescription'
import { FieldErrors } from './FieldErrors'
import { Input } from './Input'
import { Radio } from './Radio'
import { Checkbox } from './Checkbox'
import { Select } from './Select'
import { Div } from './Div'
const FieldWrapper = styled('div', {
base: {
marginBottom: 'textfield',
},
})
const StyledLabel = styled(Label, {
base: {
display: 'block',
},
})
type OmittedRACProps = 'type' | 'label' | 'items' | 'description' | 'validate'
type Items<T = ReactNode> = { items: Array<{ value: string; label: T }> }
type PartialTextFieldProps = Omit<TextFieldProps, OmittedRACProps>
type PartialCheckboxProps = Omit<CheckboxProps, OmittedRACProps>
type PartialCheckboxGroupProps = Omit<CheckboxGroupProps, OmittedRACProps>
type PartialRadioGroupProps = Omit<RadioGroupProps, OmittedRACProps>
type PartialSelectProps<T extends object> = Omit<
SelectProps<T>,
OmittedRACProps
>
type FieldProps<T extends object> = (
| ({
type: 'text'
items?: never
validate?: (
value: string
) => ReactNode | ReactNode[] | true | null | undefined
} & PartialTextFieldProps)
| ({
type: 'checkbox'
validate?: (
value: boolean
) => ReactNode | ReactNode[] | true | null | undefined
items?: never
} & PartialCheckboxProps)
| ({
type: 'checkboxGroup'
validate?: (
value: string[]
) => ReactNode | ReactNode[] | true | null | undefined
} & Items &
PartialCheckboxGroupProps)
| ({
type: 'radioGroup'
validate?: (
value: string | null
) => ReactNode | ReactNode[] | true | null | undefined
} & Items &
PartialRadioGroupProps)
| ({
type: 'select'
validate?: (value: T) => ReactNode | ReactNode[] | true | null | undefined
} & Items<string> &
PartialSelectProps<T>)
) & {
label: string
description?: string
}
/**
* Form field.
*
* This is the only component you should need when creating forms, besides the wrapping Form component.
*
* It has a specific type: a text input, a select, a checkbox, a checkbox group or a radio group.
* It can have a `description`, a help text shown below the label.
* On submit, it shows the errors that the `validate` prop returns based on the field value.
* You can render React nodes as error messages if needed, but you usually return strings.
*
* You can directly pass HTML input props if needed (like required, pattern, etc)
*/
export const Field = <T extends object>({
type,
label,
description,
items,
validate,
...props
}: FieldProps<T>) => {
const LabelAndDescription = (
<>
<StyledLabel>{label}</StyledLabel>
<FieldDescription slot="description">{description}</FieldDescription>
</>
)
const RACFieldErrors = (
<RACFieldError>
{({ validationErrors }) => {
return <FieldErrors errors={validationErrors} />
}}
</RACFieldError>
)
if (type === 'text') {
return (
<FieldWrapper>
<RACTextField
validate={validate as unknown as TextFieldProps['validate']}
{...(props as PartialTextFieldProps)}
>
{LabelAndDescription}
<Input />
{RACFieldErrors}
</RACTextField>
</FieldWrapper>
)
}
if (type === 'checkbox') {
return (
<FieldWrapper>
<Checkbox
validate={validate as unknown as CheckboxProps['validate']}
description={description}
{...(props as PartialCheckboxProps)}
>
{label}
</Checkbox>
</FieldWrapper>
)
}
if (type === 'checkboxGroup') {
return (
<FieldWrapper>
<CheckboxGroup
validate={validate as unknown as CheckboxGroupProps['validate']}
{...(props as PartialCheckboxGroupProps)}
>
{LabelAndDescription}
<Div marginTop={0.25}>
{items.map((item, index) => (
<FieldItem last={index === items.length - 1} key={item.value}>
<Checkbox size="sm" value={item.value}>
{item.label}
</Checkbox>
</FieldItem>
))}
</Div>
{RACFieldErrors}
</CheckboxGroup>
</FieldWrapper>
)
}
if (type === 'radioGroup') {
return (
<FieldWrapper>
<RadioGroup
validate={validate as unknown as RadioGroupProps['validate']}
{...(props as PartialRadioGroupProps)}
>
{LabelAndDescription}
{items.map((item, index) => (
<FieldItem last={index === items.length - 1} key={item.value}>
<Radio value={item.value}>{item.label}</Radio>
</FieldItem>
))}
{RACFieldErrors}
</RadioGroup>
</FieldWrapper>
)
}
if (type === 'select') {
return (
<FieldWrapper>
<Select
validate={validate as unknown as SelectProps<T>['validate']}
{...(props as PartialSelectProps<T>)}
label={LabelAndDescription}
errors={RACFieldErrors}
items={items}
/>
</FieldWrapper>
)
}
}
const FieldItem = ({
children,
last,
}: {
children: ReactNode
last?: boolean
}) => {
return <Div {...(!last ? { marginBottom: 0.25 } : {})}>{children}</Div>
}
@@ -0,0 +1,20 @@
import { styled } from '@/styled-system/jsx'
import { Text, TextProps } from 'react-aria-components'
const StyledDescription = styled(Text, {
base: {
display: 'block',
textStyle: 'sm',
color: 'default.subtle-text',
marginBottom: 0.125,
},
})
/**
* Styled field description.
*
* Used internally by Fields.
*/
export const FieldDescription = (props: TextProps) => {
return <StyledDescription {...props} />
}
@@ -0,0 +1,36 @@
import { styled } from '@/styled-system/jsx'
import { type ReactNode, Fragment } from 'react'
const StyledErrors = styled('div', {
base: {
display: 'block',
textStyle: 'sm',
color: 'danger',
marginTop: 0.125,
},
})
/**
* Styled list of given errors.
*
* Used internally by Fields.
*/
export const FieldErrors = ({
id,
errors,
}: {
id?: string
errors: ReactNode[]
}) => {
return (
<StyledErrors id={id}>
{errors.map((error, i) => {
return typeof error === 'string' ? (
<p key={error}>{error}</p>
) : (
<Fragment key={i}>{error}</Fragment>
)
})}
</StyledErrors>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { type FormEvent } from 'react'
import { Form as RACForm, type FormProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { HStack } from '@/styled-system/jsx'
import { Button, useCloseDialog } from '@/primitives'
/**
* From wrapper that exposes form data on submit and adds submit/cancel buttons
*
* Wrap all your Fields in this component.
* If the form is in a dialog, the cancel button closes the dialog unless you pass a custom onCancelButtonPress handler.
*/
export const Form = ({
onSubmit,
submitLabel,
withCancelButton = true,
onCancelButtonPress,
children,
...props
}: Omit<FormProps, 'onSubmit'> & {
onSubmit?: (
data: {
[k: string]: FormDataEntryValue
},
event: FormEvent<HTMLFormElement>
) => void
submitLabel: string
withCancelButton?: boolean
onCancelButtonPress?: () => void
}) => {
const { t } = useTranslation()
const closeDialog = useCloseDialog()
const onCancel = withCancelButton
? onCancelButtonPress || closeDialog
: undefined
return (
<RACForm
{...props}
onSubmit={(event) => {
event.preventDefault()
const formData = Object.fromEntries(new FormData(event.currentTarget))
if (onSubmit) {
onSubmit(formData, event)
}
}}
>
{children}
<HStack gap="gutter">
<Button type="submit" variant="primary">
{submitLabel}
</Button>
{!!onCancel && (
<Button variant="primary" outline onPress={() => onCancel()}>
{t('cancel')}
</Button>
)}
</HStack>
</RACForm>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { styled } from '@/styled-system/jsx'
import { Input as RACInput } from 'react-aria-components'
/**
* Styled RAC Input.
*
* Used internally by Fields.
*/
export const Input = styled(RACInput, {
base: {
width: 'full',
paddingY: 0.25,
paddingX: 0.5,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
transition: 'all 200ms',
},
})
+3 -1
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} />
}
+20 -1
View File
@@ -9,11 +9,30 @@ import {
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
const StyledPopover = styled(RACPopover, {
export const StyledPopover = styled(RACPopover, {
base: {
minWidth: 'var(--trigger-width)',
'&[data-entering]': {
animation: 'slide 200ms',
},
'&[data-exiting]': {
animation: 'slide 200ms reverse ease-in',
},
'&[data-placement="bottom"]': {
marginTop: 0.25,
},
'&[data-placement=top]': {
'--origin': 'translateY(8px)',
},
'&[data-placement=bottom]': {
'--origin': 'translateY(-8px)',
},
'&[data-placement=right]': {
'--origin': 'translateX(-8px)',
},
'&[data-placement=left]': {
'--origin': 'translateX(8px)',
},
},
})
+14 -4
View File
@@ -14,10 +14,20 @@ const ListItem = styled(Button, {
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'primary',
'_ra-hover': {
color: 'primary.subtle-text',
backgroundColor: 'primary.subtle',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
+89
View File
@@ -0,0 +1,89 @@
import {
type RadioProps as RACRadioProps,
Radio as RACRadio,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { type StyledVariantProps } from '@/styled-system/types'
// styled taken from example at https://react-spectrum.adobe.com/react-aria/Checkbox.html and changed for round radios
export const StyledRadio = styled(RACRadio, {
base: {
display: 'flex',
alignItems: 'center',
gap: 0.375,
forcedColorAdjust: 'none',
width: 'fit-content',
'& .mt-Radio': {
borderRadius: 'full',
flexShrink: 0,
width: '1.125rem',
height: '1.125rem',
border: '1px solid {colors.control.border}',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 200ms',
},
'& .mt-Radio-check': {
width: '0.5rem',
height: '0.5rem',
borderRadius: 'full',
backgroundColor: 'transparent',
transition: 'all 200ms',
},
'&[data-pressed] .mt-Radio': {
borderColor: 'primary.active',
},
'&[data-focus-visible] .mt-Radio': {
outline: '2px solid!',
outlineColor: 'focusRing!',
outlineOffset: '2px!',
},
'&[data-selected] .mt-Radio': {
borderColor: 'primary',
},
'&[data-selected] .mt-Radio-check': {
backgroundColor: 'primary',
},
'&[data-selected][data-pressed] .mt-Radio-check': {
backgroundColor: 'primary.active',
},
},
variants: {
size: {
sm: {
base: {},
'& .radio': {
width: '1.125rem',
height: '1.125rem',
},
'& svg': {
width: '0.625rem',
height: '0.625rem',
},
},
},
},
})
export type RadioProps = StyledVariantProps<typeof StyledRadio> & RACRadioProps
/**
* Styled radio button.
*
* Used internally by RadioGroups in Fields.
*/
export const Radio = ({ children, ...props }: RadioProps) => {
return (
<StyledRadio {...props}>
{(renderProps) => (
<>
<div className="mt-Radio" aria-hidden="true">
<div className="mt-Radio-check" />
</div>
{typeof children === 'function' ? children(renderProps) : children}
</>
)}
</StyledRadio>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { type ReactNode } from 'react'
import { styled } from '@/styled-system/jsx'
import { RiArrowDropDownLine } from '@remixicon/react'
import {
Button,
ListBox,
ListBoxItem,
Select as RACSelect,
SelectProps,
SelectValue,
} from 'react-aria-components'
import { Box } from './Box'
import { StyledPopover } from './Popover'
const StyledButton = styled(Button, {
base: {
width: 'full',
display: 'flex',
justifyContent: 'space-between',
paddingY: 0.125,
paddingX: 0.25,
border: '1px solid',
borderColor: 'control.border',
color: 'control.text',
borderRadius: 4,
boxShadow: '0 1px 2px rgba(0 0 0 / 0.1)',
'&[data-focus-visible]': {
outline: '2px solid {colors.focusRing}',
outlineOffset: '-1px',
},
'&[data-pressed]': {
backgroundColor: 'control.hover',
},
},
})
const StyledSelectValue = styled(SelectValue, {
base: {
'&[data-placeholder]': {
color: 'default.subtle-text',
fontStyle: 'italic',
},
},
})
const StyledListBoxItem = styled(ListBoxItem, {
base: {
paddingY: 0.125,
paddingX: 0.5,
textAlign: 'left',
width: 'full',
borderRadius: 4,
cursor: 'pointer',
color: 'box.text',
border: '1px solid transparent',
'&[data-selected]': {
fontWeight: 'bold',
},
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
export const Select = <T extends string | number>({
label,
items,
errors,
...props
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
label: ReactNode
items: Array<{ value: T; label: ReactNode }>
errors?: ReactNode
}) => {
return (
<RACSelect {...props}>
{label}
<StyledButton>
<StyledSelectValue />
<RiArrowDropDownLine aria-hidden="true" />
</StyledButton>
<StyledPopover>
<Box size="sm" type="popover" variant="control">
<ListBox>
{items.map((item) => (
<StyledListBoxItem id={item.value} key={item.value}>
{item.label}
</StyledListBoxItem>
))}
</ListBox>
</Box>
</StyledPopover>
{errors}
</RACSelect>
)
}
+27 -3
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: {
+93
View File
@@ -0,0 +1,93 @@
import { type ReactNode } from 'react'
import {
OverlayArrow,
Tooltip as RACTooltip,
TooltipProps,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
/**
* Styled react aria Tooltip component.
*
* Note that tooltips are directly handled by Buttons via the `tooltip` prop,
* so you should not need to use this component directly.
*
* Style taken from example at https://react-spectrum.adobe.com/react-aria/Tooltip.html
*/
const StyledTooltip = styled(RACTooltip, {
base: {
boxShadow: '0 8px 20px rgba(0 0 0 / 0.1)',
borderRadius: '4px',
backgroundColor: 'gray.800',
color: 'gray.100',
forcedColorAdjust: 'none',
outline: 'none',
padding: '2px 8px',
maxWidth: '200px',
textAlign: 'center',
fontSize: 14,
transform: 'translate3d(0, 0, 0)',
'&[data-placement=top]': {
marginBottom: '8px',
'--origin': 'translateY(4px)',
},
'&[data-placement=bottom]': {
marginTop: '8px',
'--origin': 'translateY(-4px)',
},
'&[data-placement=right]': {
marginLeft: '8px',
'--origin': 'translateX(-4px)',
},
'&[data-placement=left]': {
marginRight: '8px',
'--origin': 'translateX(4px)',
},
'& .react-aria-OverlayArrow svg': {
display: 'block',
fill: 'var(--highlight-background)',
},
'&[data-entering]': { animation: 'slide 200ms' },
'&[data-exiting]': { animation: 'slide 200ms reverse ease-in' },
},
})
const StyledOverlayArrow = styled(OverlayArrow, {
base: {
'& svg': {
display: 'block',
fill: 'gray.800',
},
'&[data-placement=bottom] svg': {
transform: 'rotate(180deg)',
},
'&[data-placement=right] svg': {
transform: 'rotate(90deg)',
},
'&[data-placement=left] svg': {
transform: 'rotate(-90deg)',
},
},
})
const TooltipArrow = () => {
return (
<StyledOverlayArrow>
<svg width={8} height={8} viewBox="0 0 8 8">
<path d="M0 0 L4 4 L8 0" />
</svg>
</StyledOverlayArrow>
)
}
export const Tooltip = ({
children,
...props
}: Omit<TooltipProps, 'children'> & { children: ReactNode }) => {
return (
<StyledTooltip {...props}>
<TooltipArrow />
{children}
</StyledTooltip>
)
}
+8
View File
@@ -0,0 +1,8 @@
import { styled } from '../styled-system/jsx'
export const Ul = styled('ul', {
base: {
listStyle: 'disc',
paddingLeft: 1,
},
})
@@ -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>
)
}
+7
View File
@@ -3,12 +3,19 @@ export { Badge } from './Badge'
export { Bold } from './Bold'
export { Box } from './Box'
export { Button } from './Button'
export { useCloseDialog } from './useCloseDialog'
export { Dialog, type DialogProps } from './Dialog'
export { Div } from './Div'
export { Field } from './Field'
export { Form } from './Form'
export { H } from './H'
export { Hr } from './Hr'
export { Italic } from './Italic'
export { Input } from './Input'
export { Link } from './Link'
export { P } from './P'
export { Popover } from './Popover'
export { PopoverList } from './PopoverList'
export { Text } from './Text'
export { Ul } from './Ul'
export { VerticallyOffCenter } from './VerticallyOffCenter'
@@ -0,0 +1,10 @@
import { useContext } from 'react'
import { OverlayTriggerStateContext } from 'react-aria-components'
export const useCloseDialog = () => {
const dialogState = useContext(OverlayTriggerStateContext)
if (dialogState) {
return dialogState.close
}
return null
}
+32
View File
@@ -0,0 +1,32 @@
import { FeedbackRoute, RoomRoute, roomIdPattern } from '@/features/rooms'
import { HomeRoute } from '@/features/home'
export const routes: Record<
'home' | 'room' | 'feedback',
{
name: RouteName
path: RegExp | string
Component: () => JSX.Element
// eslint-disable-next-line @typescript-eslint/no-explicit-any
to?: (...args: any[]) => string | URL
}
> = {
home: {
name: 'home',
path: '/',
Component: HomeRoute,
},
room: {
name: 'room',
path: new RegExp(`^[/](?<roomId>${roomIdPattern})$`),
to: (roomId: string) => `/${roomId.trim()}`,
Component: RoomRoute,
},
feedback: {
name: 'feedback',
path: '/feedback',
Component: FeedbackRoute,
},
}
export type RouteName = keyof typeof routes
-5
View File
@@ -1,5 +0,0 @@
import { NotFoundScreen } from '@/layout/NotFoundScreen'
export const NotFound = () => {
return <NotFoundScreen />
}
+13 -6
View File
@@ -8,13 +8,20 @@ body,
}
* {
outline: none;
outline: 2px solid transparent;
}
[data-rac][data-focus-visible],
:is(a, button, input, select, textarea):not([data-rac]):focus-visible {
outline: 2px solid black;
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
:is(a, button, input[type='text'], select, textarea):not(
[data-rac]
):focus-visible {
outline: 2px solid var(--colors-focus-ring);
outline-offset: 1px;
outline-color: Highlight;
outline-color: -webkit-focus-ring-color;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.1"
tag: "v0.1.2"
backend:
migrateJobAnnotations:
@@ -106,7 +106,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.1"
tag: "v0.1.2"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/meet-backend
pullPolicy: Always
tag: "v0.1.1"
tag: "v0.1.2"
backend:
migrateJobAnnotations:
@@ -106,7 +106,7 @@ frontend:
image:
repository: lasuite/meet-frontend
pullPolicy: Always
tag: "v0.1.1"
tag: "v0.1.2"
ingress:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.1",
"version": "0.1.2",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
-25
View File
@@ -1,25 +0,0 @@
{
"name": "meet-openapi-client-ts",
"version": "0.1.1",
"private": true,
"description": "Tool to generate Typescript API client for the Meet application.",
"scripts": {
"generate:api:client:local": "./scripts/openapi-typescript-codegen/generate_api_client_local.sh $1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/numerique-gouv/meet.git"
},
"author": {
"name": "DINUM",
"email": "dev@mail.numerique.gouv.fr"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/numerique-gouv/meet/issues"
},
"homepage": "https://github.com/numerique-gouv/meet#readme",
"devDependencies": {
"openapi-typescript-codegen": "0.29.0"
}
}
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# usage: yarn generate:api:client:local [--output]
# OPTIONS:
# --output the path folder where types will be generated
openapi --input http://app-dev:8000/v1.0/swagger.json --output $1 --indent='2' --name ApiClientImpress --useOptions
-120
View File
@@ -1,120 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@apidevtools/json-schema-ref-parser@^11.5.4":
version "11.5.4"
resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.5.4.tgz#6a90caf2140834025cf72651280c46084de187ae"
integrity sha512-o2fsypTGU0WxRxbax8zQoHiIB4dyrkwYfcm8TxZ+bx9pCzcWZbQtiMqpgBvWA/nJ2TrGjK5adCLfTH8wUeU/Wg==
dependencies:
"@jsdevtools/ono" "^7.1.3"
"@types/json-schema" "^7.0.15"
js-yaml "^4.1.0"
"@jsdevtools/ono@^7.1.3":
version "7.1.3"
resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796"
integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
camelcase@^6.3.0:
version "6.3.0"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
commander@^12.0.0:
version "12.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-12.0.0.tgz#b929db6df8546080adfd004ab215ed48cf6f2592"
integrity sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==
fs-extra@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b"
integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==
dependencies:
graceful-fs "^4.2.0"
jsonfile "^6.0.1"
universalify "^2.0.0"
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.10"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
handlebars@^4.7.8:
version "4.7.8"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
source-map "^0.6.1"
wordwrap "^1.0.0"
optionalDependencies:
uglify-js "^3.1.4"
js-yaml@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
dependencies:
argparse "^2.0.1"
jsonfile@^6.0.1:
version "6.1.0"
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==
dependencies:
universalify "^2.0.0"
optionalDependencies:
graceful-fs "^4.1.6"
minimist@^1.2.5:
version "1.2.8"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
neo-async@^2.6.2:
version "2.6.2"
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
openapi-typescript-codegen@0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz#e98a1daa223ccdeb1cc51b2e2dc11bafae6fe746"
integrity sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w==
dependencies:
"@apidevtools/json-schema-ref-parser" "^11.5.4"
camelcase "^6.3.0"
commander "^12.0.0"
fs-extra "^11.2.0"
handlebars "^4.7.8"
source-map@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
uglify-js@^3.1.4:
version "3.17.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"
integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==
universalify@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717"
integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==