Compare commits

...

79 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
Emmanuel Pelletier 195e701fc4 🔖(minor) bump release to 0.1.1 2024-07-22 15:57:57 +02:00
lebaudantoine 88a5717022 ♻️(backend) simplify queryset while listing rooms
Recent refactoring simplified the DB models.
Thus, filtering rooms is now way simpler,
I updated the subsequent queryset.
2024-07-22 14:15:49 +02:00
lebaudantoine e17d42ebe3 🔥(backend) remove todo items
The Pylint job was failing due to those TODO items. In our make lint
command sequence, Pylint runs first. If it fails, Ruff won't run,
which is quite inconvenient.

I've extracted those TODOs into an issue for further review.
2024-07-22 14:15:49 +02:00
lebaudantoine ae95a00301 (backend) add 'username' query param when retrieving a room
Quick and dirty approach. It works, that's essential.
Frontend can pass a desired username for the user. This would
be the name displayed in the room to other participants.
Usernames don't need to be unique, but user identities do

If no username is passed, API will fall back to a default username.
Why? This serves as a security mechanism. If the API is called
incorrectly by a client, it maintains the previous behavior.
2024-07-22 14:15:49 +02:00
Emmanuel Pelletier faff1c1228 ♻️(frontend) make the Conference component not know about routing
Makes more sense that way: only the Room _route_ knows about route
params, not the internal component used.
2024-07-21 17:42:53 +02:00
Emmanuel Pelletier e04b8d081f 🐛(frontend) have a suspense fallback
the app crashed on screen changes, I don't quite get why right now… but
at least this goes around the issue…
2024-07-21 17:39:08 +02:00
Emmanuel Pelletier efb5ac5834 🌐(frontend) sort extrated locales to help prevent conflicts
when adding keys by hand, we didn't really know where to add them so
that the i18n:extract command would not move them afterwards. Feels like
this will help.
I guess a CI thing checking if the locales file dont change after a push
would be helpful
2024-07-21 17:26:26 +02:00
Emmanuel Pelletier 0cf4960969 ♻️(rooms) room id gen: write more es6-like code
- this feels a bit less boilerplaty to read
- puting the characters whitelist outside the function to prevent
creating the var each time (yes, this of super great importance)
2024-07-21 17:18:29 +02:00
Emmanuel Pelletier f11bcea3a2 🔒️(frontend) valide ':roomId' path using a regex
Enhanced security by ensuring users are redirected to a 404 error page
if they
pass an incorrect roomId path, either intentionally or unintentionally.
This is
a critical security mechanism that should be included in our MVP.

Let's discuss extracting hardcoded elements, such as lengths or
the separator, into proper constants to improve code maintainability.
I was concerned that this might make the code harder to read, it could
enhance
clarity and reusability in the long term.

I prefer exposing the roomIdRegex from the same location where we
generate IDs.
However, this increases the responsibility of that file. Lmk if you have
any
suggestion for a better organization.

Additionally, the current 404 error page displays a 'Page not found'
message for
invalid room IDs. Should we update this message to 'Invalid room name'
to
provide more context to the user?
2024-07-21 17:18:29 +02:00
lebaudantoine d8c8ac0811 🚸(frontend) generate shorter room IDs making URLs easier to share
UUID-v4 room IDs are long and uninviting. Shorter, custom room IDs
can enhance UX by making URLs easier to share and remember.

While UUID-v4s are typically used in database systems for their low
collision probability, for ephemeral room IDs, the collision risk of e+14
combinations is acceptable.

This aligns room IDs with Google Meet format.

Even if the 'slugify' function is not used anymore, I kept it.
Lmk if you prefer removing it @manuhabitela
2024-07-21 17:18:29 +02:00
Emmanuel Pelletier 9fd1af2302 💄(keyboard) better handling of focus ring
some focus rings were shown even when we only used the mouse. this
globally fixes that
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier e6c292ecd7 🌐(frontend) add a language selector in the header
this will be better in an options page later i think, as we don't pass
our life changing language and we already have a language detector at
load.

this adds a PopoverList primitive to easily create buttons triggering
popovers containing list of actionable items.
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier d6b5e9a50c (frontend) add a Popover primitive
easily use buttons toggling styled RAC popovers
2024-07-21 16:48:27 +02:00
Emmanuel Pelletier 789bce5092 ♻️(frontend) put homepage in its own feature
makes more sense i guess, maybe
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier f888fc1717 🌐(crowdin) make crowdin work with frontend translations
- upload local translation files on push
- make crowdin create a pull request when new translations are made
through the crowdin website (webhook configured on crowdin-end)
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier 545877febb 💚(crowdin) update to latest secrets to fix CROWDIN_BASE_PATH issue
the base path is actually not a secret so we'd rather have it outside
secrets and see it easily
2024-07-20 20:23:57 +02:00
Emmanuel Pelletier d2dba511e2 🌐(frontend) init i18next
- dynamically load locale files for smaller footprint
- have a namespace for each feature. At first I'd figured I'd put each
namespace in its correct feature folder but it's kinda cumbersome to
manage if we want to link that to i18n management services like crowdin…
2024-07-20 20:23:57 +02:00
antoine lebaud 84c2986c01 ✏️(makefile) fix "crowin" typo
Fixed a typo and ensured all instances of "crowdin"
are capitalized for consistent naming.
2024-07-20 20:23:57 +02:00
antoine lebaud 44e5cd6ef3 💚(CI) fix crowdin steps
Updated CI to use "npm" instead of yarn for the frontend project based
on @manuhabitela's recommendations. Also updated the dependencies-related CI
steps that were previously missed.
2024-07-20 20:23:57 +02:00
Jacques ROUSSEL 7510d0fc2b 🔧(helm) configuration
Change configuration to use livekit-preprod.beta.numerique.gouv.fr
instead of the docker test vm
2024-07-19 15:35:55 +02:00
Jacques ROUSSEL f50426b11a 🔧(helm) fix helm chart
Fix helm secret to be abble to use titl on dev
2024-07-18 16:11:56 +02:00
lebaudantoine b604235c35 📝(release) document releasing new version
Heavily inspired from openfun handbook instructions,
https://handbook.openfun.fr/git

Document how release a new version. Might be a common
documentation shared with Impress, Regie and Meet projects.
Let's discuss it.

It's quite important that anyone should know how to release,
as we plan to release every week an enhanced version.
2024-07-18 16:03:19 +02:00
lebaudantoine 6e20d5385f ♻️(frontend) introduce a logoutUrl function
Wrap the logout URL in a function for consistency with '/authenticate'.
2024-07-17 16:51:24 +02:00
lebaudantoine 1c046abf5f ✏️(frontend) minor typo detected on webstorm
No big deal, just a little nit-pick. Nothing personal!
My IDE is THE nit-picker.
2024-07-17 16:51:24 +02:00
lebaudantoine 3718851435 ♻️(frontend) refactor hardcoded '/authenticate' API calls
Use the function introduce by @manuhabitela, authUrl.
It reduces code duplication.
2024-07-17 16:51:24 +02:00
Jacques ROUSSEL c390499394 🔧(helm) fix helm chart
Add md5sum on secret in order to automatically deploy new pods when
secret change
2024-07-17 15:50:18 +02:00
Jacques ROUSSEL 980d3c19d8 🔧(helm) upgrade sops secrets
Upgrade submodule reference
2024-07-17 15:50:18 +02:00
116 changed files with 4344 additions and 598 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Download Crowdin translations
on:
workflow_dispatch:
types: [file-fully-translated]
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Crowdin files
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: "New Crowdin translations"
pull_request_body: "New Crowdin pull request with translations"
pull_request_base_branch_name: "main"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
+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 }}
+20 -21
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"
@@ -208,22 +205,24 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "yarn"
cache-dependency-path: src/frontend/yarn.lock
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: cd src/frontend/ && yarn install --frozen-lockfile
run: cd src/frontend/ && npm ci
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
run: |
docker run \
--rm \
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
-v "${{ github.workspace }}:/app" \
crowdin/cli:3.16.0 \
crowdin upload sources -c /app/crowdin/config.yml
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: true
download_translations: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-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"
+10 -26
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
@@ -217,7 +216,7 @@ env.d/development/kc_postgresql:
env.d/development/crowdin:
cp -n env.d/development/crowdin.dist env.d/development/crowdin
crowdin-download: ## Download translated message from crowdin
crowdin-download: ## Download translated message from Crowdin
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
.PHONY: crowdin-download
@@ -225,14 +224,17 @@ crowdin-download-sources: ## Download sources from Crowdin
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
.PHONY: crowdin-download-sources
crowdin-upload: ## Upload source translations to crowdin
crowdin-upload: ## Upload source translations to Crowdin
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
.PHONY: crowdin-upload
crowdin-upload-translations: ## Upload translations to Crowdin
@$(COMPOSE_RUN_CROWDIN) upload translations -c crowdin/config.yml
.PHONY: crowdin-upload-translations
i18n-compile: ## compile all translations
i18n-compile: \
back-i18n-compile \
frontend-i18n-compile
back-i18n-compile
.PHONY: i18n-compile
i18n-generate: ## create the .pot files and extract frontend messages
@@ -272,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
@@ -295,23 +286,16 @@ help:
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
.PHONY: help
# FIXME : adapt this command
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
cd $(PATH_FRONT) && yarn i18n:extract
frontend-i18n-extract: ## Check the frontend code and generate missing translations keys in translation files
cd $(PATH_FRONT) && npm run i18n:extract
.PHONY: frontend-i18n-extract
# FIXME : adapt this command
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
frontend-i18n-generate: ## Generate the frontend json files used for Crowdin
frontend-i18n-generate: \
crowdin-download-sources \
frontend-i18n-extract
.PHONY: frontend-i18n-generate
# FIXME : adapt this command
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
cd $(PATH_FRONT) && yarn i18n:deploy
.PHONY: frontend-i18n-compile
# -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind
./bin/start-kind.sh
+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
+15 -14
View File
@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_API_TOKEN
api_token_env: CROWDIN_PERSONAL_TOKEN
project_id_env: CROWDIN_PROJECT_ID
base_path_env: CROWDIN_BASE_PATH
@@ -14,16 +14,17 @@ preserve_hierarchy: true
#
# Files configuration
#
files: [
{
source : "/backend/locale/django.pot",
dest: "/backend-meet.pot",
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
},
{
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
dest: "/frontend-impress.json",
translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json",
skip_untranslated_strings: true,
},
]
files:
[
{
source: "src/backend/locale/django.pot",
dest: "/backend-meet.pot",
translation: "src/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po",
},
{
source: "src/frontend/src/locales/fr/**/*",
translation: "src/frontend/src/locales/%two_letters_code%/**/%original_file_name%",
dest: "/%original_file_name%",
skip_untranslated_strings: true,
},
]
+1 -1
View File
@@ -100,7 +100,7 @@ services:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
image: crowdin/cli:4.0.0
volumes:
- ".:/app"
env_file:
+57
View File
@@ -0,0 +1,57 @@
# 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 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
```
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
+2 -2
View File
@@ -1,3 +1,3 @@
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PERSONAL_TOKEN=Your-Api-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src
CROWDIN_BASE_PATH=/app
+1 -1
Submodule secrets updated: 35ec1ef9fe...9da011f5c2
+5 -3
View File
@@ -122,14 +122,16 @@ class RoomSerializer(serializers.ModelSerializer):
if role is not None or instance.is_public:
slug = f"{instance.id!s}".replace("-", "")
username = request.GET.get("username", None)
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(room=slug, user=request.user),
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
}
output["is_administrable"] = is_admin
# todo - pass properly livekit configuration
return output
+5 -5
View File
@@ -188,12 +188,15 @@ class RoomViewSet(
if not settings.ALLOW_UNREGISTERED_ROOMS:
raise
slug = slugify(self.kwargs["pk"])
username = request.query_params.get("username", None)
data = {
"id": None,
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(room=slug, user=request.user),
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
},
}
else:
@@ -206,11 +209,8 @@ class RoomViewSet(
user = self.request.user
if user.is_authenticated:
# todo - simplify this queryset
queryset = (
self.filter_queryset(self.get_queryset())
.filter(Q(users=user))
.distinct()
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
)
else:
queryset = self.get_queryset().none()
+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")
@@ -111,7 +111,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
},
}
mock_token.assert_called_once_with(room="unregistered-room", user=AnonymousUser())
mock_token.assert_called_once_with(
room="unregistered-room", user=AnonymousUser(), username=None
)
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
@@ -141,7 +143,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
},
}
mock_token.assert_called_once_with(room="reunion", user=AnonymousUser())
mock_token.assert_called_once_with(
room="reunion", user=AnonymousUser(), username=None
)
@override_settings(ALLOW_UNREGISTERED_ROOMS=False)
@@ -153,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")
@@ -229,7 +233,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
def test_api_rooms_retrieve_authenticated():
@@ -327,7 +331,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
@mock.patch("core.utils.generate_token", return_value="foo")
@@ -400,4 +404,4 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
"slug": room.slug,
}
mock_token.assert_called_once_with(room=expected_name, user=user)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
+9 -6
View File
@@ -1,7 +1,7 @@
"""
Utils functions used in the core app
"""
import string
from typing import Optional
from uuid import uuid4
from django.conf import settings
@@ -9,18 +9,19 @@ from django.conf import settings
from livekit.api import AccessToken, VideoGrants
def generate_token(room: string, user) -> str:
def generate_token(room: str, user, username: Optional[str] = None) -> str:
"""Generate a Livekit access token for a user in a specific room.
Args:
room (str): The name of the room.
user (User): The user which request the access token.
username (Optional[str]): The username to be displayed in the room.
If none, a default value will be used.
Returns:
str: The LiveKit JWT access token.
"""
# todo - define the video grants properly based on user and room.
video_grants = VideoGrants(
room=room,
room_join=True,
@@ -38,10 +39,12 @@ def generate_token(room: string, user) -> str:
).with_grants(video_grants)
if user.is_anonymous:
# todo - allow passing a proper name for not logged-in user
token.with_identity(str(uuid4()))
default_username = "Anonymous"
else:
# todo - use user's fullname instead of its email for the displayed name
token.with_identity(user.sub).with_name(f"{user!s}")
token.with_identity(user.sub)
default_username = str(user)
token.with_name(username or default_username)
return token.to_jwt()
+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.0"
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",
+8
View File
@@ -0,0 +1,8 @@
{
"defaultNamespace": "global",
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
"output": "src/locales/$LOCALE/$NAMESPACE.json",
"createOldCatalogs": false,
"locales": ["en", "fr", "de"],
"sort": true
}
+1515 -41
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,23 +1,31 @@
{
"name": "meet",
"private": true,
"version": "0.1.0",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
"build": "panda codegen && tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json"
},
"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",
"i18next-browser-languagedetector": "8.0.0",
"i18next-parser": "9.0.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.3.1",
"react": "18.2.0",
"react-aria-components": "1.2.1",
"react-dom": "18.2.0",
"react-i18next": "14.1.3",
"wouter": "3.3.0"
},
"devDependencies": {
+35 -23
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.
@@ -146,6 +147,7 @@ const config: Config = {
2: { value: '2' },
},
radii: {
4: { value: '0.25rem' },
6: { value: '0.375rem' },
8: { value: '0.5rem' },
16: { value: '1rem' },
@@ -164,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}' },
},
@@ -178,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}' },
@@ -187,7 +190,7 @@ const config: Config = {
text: { value: '{colors.white}' },
warm: { value: '{colors.blue.300}' },
subtle: { value: '{colors.blue.100}' },
'subtle-text': { value: '{colors.sky.700}' },
'subtle-text': { value: '{colors.blue.700}' },
},
danger: {
DEFAULT: { value: '{colors.red.600}' },
@@ -198,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}' },
@@ -213,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}' },
@@ -228,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',
@@ -252,7 +265,6 @@ const config: Config = {
value: {
fontSize: '1.125rem',
lineHeight: '1.75rem',
fontWeight: 700,
},
},
body: {
@@ -261,7 +273,7 @@ const config: Config = {
lineHeight: '1.5',
},
},
small: {
sm: {
value: {
fontSize: '0.875rem',
lineHeight: '1.25rem',
+21 -10
View File
@@ -1,23 +1,34 @@
import '@livekit/components-styles'
import '@/styles/index.css'
import { Suspense } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Route, Switch } from 'wouter'
import { Home } from './routes/Home'
import { NotFound } from './routes/NotFound'
import { RoomRoute } from '@/features/rooms'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
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()
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
return (
<QueryClientProvider client={queryClient}>
<Switch>
<Route path="/" component={Home} />
<Route path="/:roomId" component={RoomRoute} />
<Route component={NotFound} />
</Switch>
<ReactQueryDevtools initialIsOpen={false} />
<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,9 +1,10 @@
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.
* fetch the logged-in user from the api.
*
* If the user is not logged in, the api returns a 401 error.
* Here our wrapper just returns false in that case, without triggering an error:
@@ -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} />
)
}
+2
View File
@@ -1,2 +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,5 @@
import { apiUrl } from '@/api/apiUrl'
export const logoutUrl = () => {
return apiUrl('/logout')
}
@@ -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
@@ -0,0 +1 @@
export { Home as HomeRoute } from './routes/Home'
@@ -0,0 +1,56 @@
import { useTranslation } from 'react-i18next'
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 { Screen } from '@/layout/Screen'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
export const Home = () => {
const { t } = useTranslation('home')
const { isLoggedIn } = useUser()
return (
<Screen>
<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>
<JoinMeetingDialog />
</DialogTrigger>
</HStack>
</Div>
</VerticallyOffCenter>
</Screen>
)
}
@@ -1,21 +1,26 @@
import { useParams } from 'wouter'
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 '@/navigation/navigateToHome'
import { fetchRoom } from '../api/fetchRoom'
import { InviteDialog } from './InviteDialog'
export const Conference = ({
roomId,
userConfig,
mode = 'join',
}: {
roomId: string
userConfig: LocalUserChoices
mode?: 'join' | 'create'
}) => {
const { roomId } = useParams()
const { status, data } = useQuery({
queryKey: [keys.room, roomId, userConfig.username],
queryFn: () =>
@@ -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>
)
}
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { Box } from '@/layout/Box'
import { PreJoin, type LocalUserChoices } from '@livekit/components-react'
@@ -6,11 +7,17 @@ export const Join = ({
}: {
onSubmit: (choices: LocalUserChoices) => void
}) => {
const { t } = useTranslation('rooms')
return (
<Box title="Verify your settings before joining" withBackButton>
<Box title={t('join.heading')} withBackButton>
<PreJoin
persistUserChoices
onSubmit={onSubmit}
micLabel={t('join.micLabel')}
camLabel={t('join.camlabel')}
joinLabel={t('join.joinLabel')}
userLabel={t('join.userLabel')}
/>
</Box>
)
+3 -1
View File
@@ -1,2 +1,4 @@
export { navigateToNewRoom } from './navigation/navigateToNewRoom'
export { Room as RoomRoute } from './routes/Room'
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>
)
}
@@ -1,18 +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 { Screen } from '@/layout/Screen'
import { ErrorScreen } from '@/layout/ErrorScreen'
import { useUser } from '@/features/auth'
import { Conference } from '../components/Conference'
import { Join } from '../components/Join'
import { Screen } from '@/layout/Screen'
export const Room = () => {
const { user, isLoggedIn } = useUser()
const { userChoices: existingUserChoices } = usePersistentUserChoices()
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
return (
<Screen>
{userConfig ? (
<Conference userConfig={userConfig} />
) : (
const { roomId } = useParams()
const mode = isLoggedIn && history.state?.create ? 'create' : 'join'
const skipJoinScreen = isLoggedIn && mode === 'create'
if (!roomId) {
return <ErrorScreen />
}
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,
}}
/>
)
}
@@ -1,5 +1,14 @@
import { slugify } from '@/utils/slugify'
// Google Meet uses only letters in a room identifier
const ROOM_ID_ALLOWED_CHARACTERS = 'abcdefghijklmnopqrstuvwxyz'
export const generateRoomId = () => {
return slugify(crypto.randomUUID())
}
const getRandomChar = () =>
ROOM_ID_ALLOWED_CHARACTERS[
Math.floor(Math.random() * ROOM_ID_ALLOWED_CHARACTERS.length)
]
const generateSegment = (length: number): string =>
Array.from(Array(length), getRandomChar).join('')
// Generates a unique room identifier following the Google Meet format
export const generateRoomId = () =>
[generateSegment(3), generateSegment(4), generateSegment(3)].join('-')
@@ -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'
@@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
import { Button, Popover, PopoverList } from '@/primitives'
import { useLanguageLabels } from './useLanguageLabels'
export const LanguageSelector = () => {
const { t, i18n } = useTranslation()
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Popover aria-label={t('languageSelector.popoverLabel')}>
<Button
aria-label={t('languageSelector.buttonLabel', {
currentLanguage: currentLanguage.label,
})}
size="sm"
variant="primary"
outline
>
{i18n.language}
</Button>
<PopoverList
items={languagesList}
onAction={(lang) => {
i18n.changeLanguage(lang)
}}
/>
</Popover>
)
}
+23
View File
@@ -0,0 +1,23 @@
import i18n from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
const i18nDefaultNamespace = 'global'
i18n.setDefaultNamespace(i18nDefaultNamespace)
i18n
.use(
resourcesToBackend((language: string, namespace: string) => {
return import(`../locales/${language}/${namespace}.json`)
})
)
.use(initReactI18next)
.use(LanguageDetector)
i18n.init({
supportedLngs: ['en', 'fr'],
fallbackLng: 'en',
ns: i18nDefaultNamespace,
interpolation: {
escapeValue: false,
},
})
@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next'
const langageLabels: Record<string, string> = {
en: 'English',
fr: 'Français',
de: 'Deutsch',
}
export const useLanguageLabels = () => {
const { i18n } = useTranslation()
// cimode is a testing value from i18next, don't include it
const supportedLanguages = (i18n.options.supportedLngs || []).filter(
(lang) => lang !== 'cimode'
)
const languagesList = supportedLanguages.map((lang) => ({
value: lang,
label: langageLabels[lang],
}))
return {
languagesList,
currentLanguage: {
key: i18n.language,
label: langageLabels[i18n.language],
},
}
}
+16 -12
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import { Box as BoxDiv, H, Link } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { Box as BoxDiv, H, Link, VerticallyOffCenter } from '@/primitives'
export type BoxProps = {
children?: ReactNode
@@ -12,17 +13,20 @@ export const Box = ({
title = '',
withBackButton = false,
}: BoxProps) => {
const { t } = useTranslation()
return (
<BoxDiv asScreen>
{!!title && <H lvl={1}>{title}</H>}
{children}
{!!withBackButton && (
<p>
<Link to="/" size="small">
Back to homescreen
</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>
)
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ErrorScreen = () => {
return (
<BoxScreen title="An error occured while loading the page" withBackButton />
)
const { t } = useTranslation()
return <BoxScreen title={t('error.heading')} withBackButton />
}
+3 -6
View File
@@ -1,10 +1,7 @@
import { BoxScreen } from './BoxScreen'
import { useTranslation } from 'react-i18next'
export const ForbiddenScreen = () => {
return (
<BoxScreen
title="You don't have the permission to view this page"
withBackButton
/>
)
const { t } = useTranslation()
return <BoxScreen title={t('forbidden.heading')} withBackButton />
}
+74 -29
View File
@@ -1,47 +1,92 @@
import { Link } from 'wouter'
import { css } from '@/styled-system/css'
import { flex } from '@/styled-system/patterns'
import { apiUrl } from '@/api/apiUrl'
import { A, Badge, Text } from '@/primitives'
import { useUser } from '@/features/auth/api/useUser'
import { Stack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
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',
})}
>
<header
className={flex({
justify: 'space-between',
align: 'center',
<div
className={css({
display: 'flex',
flexDirection: 'column',
rowGap: 1,
md: {
rowGap: 0,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
})}
>
<div>
<Text bold variant="h1" margin={false}>
Meet
</Text>
</div>
<div>
{isLoggedIn === false && <A href={apiUrl('/authenticate')}>Login</A>}
{!!user && (
<p className={flex({ gap: 1, align: 'center' })}>
<Badge>{user.email}</Badge>
<A href={apiUrl('/logout')} size="small">
Logout
</A>
</p>
)}
</div>
</header>
<header>
<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">
{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>
</div>
</div>
)
}
+18 -4
View File
@@ -1,21 +1,35 @@
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>Loading</p>
<VerticallyOffCenter>
<Center>
<p>{t('loading')}</p>
</Center>
</VerticallyOffCenter>
</Container>
)
}
+3 -1
View File
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'
import { BoxScreen } from './BoxScreen'
export const NotFoundScreen = () => {
return <BoxScreen title="Page not found" withBackButton />
const { t } = useTranslation()
return <BoxScreen title={t('notFound.heading')} withBackButton />
}
+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}
+24
View File
@@ -0,0 +1,24 @@
{
"app": "Meet",
"backToHome": "",
"cancel": "",
"closeDialog": "",
"error": {
"heading": ""
},
"feedbackAlert": "",
"forbidden": {
"heading": ""
},
"languageSelector": {
"buttonLabel": "",
"popoverLabel": ""
},
"loading": "",
"loggedInUserTooltip": "",
"login": "Anmelden",
"logout": "",
"notFound": {
"heading": ""
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"createMeeting": "",
"heading": "",
"intro": "",
"joinInputError": "",
"joinInputExample": "",
"joinInputLabel": "",
"joinInputSubmit": "",
"joinMeeting": "",
"joinMeetingTipContent": "",
"joinMeetingTipHeading": "",
"loginToCreateMeeting": ""
}
+20
View File
@@ -0,0 +1,20 @@
{
"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": ""
}
+24
View File
@@ -0,0 +1,24 @@
{
"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"
},
"languageSelector": {
"buttonLabel": "Change language (currently {{currentLanguage}})",
"popoverLabel": "Choose language"
},
"loading": "Loading…",
"loggedInUserTooltip": "Logged in as…",
"login": "Login",
"logout": "Logout",
"notFound": {
"heading": "Page not found"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"createMeeting": "Create a meeting",
"heading": "Welcome in Meet",
"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"
}
+20
View File
@@ -0,0 +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": {
"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"
}
+24
View File
@@ -0,0 +1,24 @@
{
"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"
},
"languageSelector": {
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})",
"popoverLabel": "Choix de la langue"
},
"loading": "Chargement…",
"loggedInUserTooltip": "Connecté en tant que…",
"login": "Se connecter",
"logout": "Se déconnecter",
"notFound": {
"heading": "Page introuvable"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"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"
}
+20
View File
@@ -0,0 +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": {
"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)
}
@@ -1,5 +0,0 @@
import { navigate } from 'wouter/use-browser-location'
export const navigateToHome = () => {
navigate(`/`)
}
@@ -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
}
+7 -6
View File
@@ -5,19 +5,20 @@ const link = cva({
base: {
textDecoration: 'underline',
textUnderlineOffset: '2',
transition: 'all 200ms',
cursor: 'pointer',
'_ra-hover': {
borderRadius: 2,
transition: 'all 0.2s',
'&[data-hovered]': {
textDecoration: 'none',
},
'_ra-pressed': {
'&[data-pressed]': {
textDecoration: 'underline',
},
},
variants: {
size: {
small: {
textStyle: 'small',
sm: {
textStyle: 'sm',
},
},
},
@@ -26,7 +27,7 @@ const link = cva({
export type AProps = LinkProps & RecipeVariantProps<typeof link>
/**
* anchor component styled with underline
* anchor component styled with underline. Used mostly for external links. Use Link for internal links
*/
export const A = ({ size, ...props }: AProps) => {
return <Link {...props} className={link({ size })} />
+1 -1
View File
@@ -10,7 +10,7 @@ const badge = cva({
},
variants: {
size: {
small: {
sm: {
textStyle: 'badge',
},
normal: {},
+19 -5
View File
@@ -3,19 +3,30 @@ import { styled } from '../styled-system/jsx'
const box = cva({
base: {
position: 'relative',
gap: 'gutter',
borderRadius: 8,
padding: 'boxPadding',
flex: 1,
},
variants: {
asScreen: {
true: {
type: {
screen: {
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: {
@@ -25,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: {
@@ -42,7 +57,6 @@ const box = cva({
},
},
defaultVariants: {
asScreen: false,
variant: 'default',
size: 'default',
},
+134 -28
View File
@@ -1,58 +1,164 @@
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',
paddingX: '1',
paddingY: '0.625',
transition: 'all 200ms',
borderRadius: 8,
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',
'&[data-hovered]': {
backgroundColor: 'colorPalette.hover',
},
'&[data-pressed]': {
backgroundColor: 'colorPalette.active',
},
},
variants: {
size: {
default: {
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: {
default: {
color: 'control.text',
backgroundColor: 'control',
'_ra-hover': {
backgroundColor: 'control.hover',
},
'_ra-pressed': {
backgroundColor: 'control.active',
},
colorPalette: 'control',
},
primary: {
color: 'primary.text',
backgroundColor: 'primary',
'_ra-hover': {
backgroundColor: 'primary.hover',
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',
},
'_ra-pressed': {
backgroundColor: 'primary.active',
'&[data-pressed]': {
backgroundColor: 'success.subtle!',
},
},
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'&[data-hovered]': {
backgroundColor: 'colorPalette.subtle!',
},
'&[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',
variant: 'default',
outline: false,
},
})
type ButtonProps = RecipeVariantProps<typeof button> &
(RACButtonsProps | LinkProps)
type Tooltip = {
tooltip?: string
tooltipType?: 'instant' | 'delayed'
}
export type ButtonProps = RecipeVariantProps<typeof button> &
RACButtonsProps &
Tooltip
export const Button = (props: ButtonProps) => {
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps & Tooltip
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
export const Button = ({
tooltip,
tooltipType = 'instant',
...props
}: ButtonOrLinkProps) => {
const [variantProps, componentProps] = button.splitVariantProps(props)
if ((props as LinkProps).href !== undefined) {
return <Link className={button(variantProps)} {...componentProps} />
if ((props as LinkButtonProps).href !== undefined) {
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} />
}
+87
View File
@@ -0,0 +1,87 @@
import { ReactNode } from 'react'
import {
DialogProps,
DialogTrigger,
Popover as RACPopover,
Dialog,
OverlayArrow,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
import { Box } from './Box'
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)',
},
},
})
const StyledOverlayArrow = styled(OverlayArrow, {
base: {
display: 'block',
fill: 'box.bg',
stroke: 'box.border',
strokeWidth: 1,
'&[data-placement="bottom"] svg': {
transform: 'rotate(180deg) translateY(-1px)',
},
},
})
/**
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
*/
export const Popover = ({
children,
...dialogProps
}: {
children: [
trigger: ReactNode,
popoverContent:
| (({ close }: { close: () => void }) => ReactNode)
| ReactNode,
]
} & DialogProps) => {
const [trigger, popoverContent] = children
return (
<DialogTrigger>
{trigger}
<StyledPopover>
<StyledOverlayArrow>
<svg width={12} height={12} viewBox="0 0 12 12">
<path d="M0 0 L6 6 L12 0" />
</svg>
</StyledOverlayArrow>
<Dialog {...dialogProps}>
{({ close }) => (
<Box size="sm" type="popover">
{typeof popoverContent === 'function'
? popoverContent({ close })
: popoverContent}
</Box>
)}
</Dialog>
</StyledPopover>
</DialogTrigger>
)
}
@@ -0,0 +1,71 @@
import { ReactNode, useContext } from 'react'
import {
ButtonProps,
Button,
OverlayTriggerStateContext,
} from 'react-aria-components'
import { styled } from '@/styled-system/jsx'
const ListItem = styled(Button, {
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!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primary',
outline: 'none!',
},
},
})
/**
* render a Button primitive that shows a popover showing a list of pressable items
*/
export const PopoverList = <T extends string | number = string>({
onAction,
closeOnAction = true,
items = [],
}: {
closeOnAction?: boolean
onAction: (key: T) => void
items: Array<string | { value: T; label: ReactNode }>
} & ButtonProps) => {
const popoverState = useContext(OverlayTriggerStateContext)!
return (
<ul>
{items.map((item) => {
const value = typeof item === 'string' ? item : item.value
const label = typeof item === 'string' ? item : item.label
return (
<li>
<ListItem
key={value}
onPress={() => {
onAction(value as T)
if (closeOnAction) {
popoverState.close()
}
}}
>
{label}
</ListItem>
</li>
)
})}
</ul>
)
}
+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>
)
}

Some files were not shown because too many files have changed in this diff Show More