mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62492d1411 | |||
| 34e21b77b5 | |||
| 9edb9c2f57 | |||
| ee1abbed04 | |||
| ed52123733 | |||
| a7739efb70 | |||
| 72ef3c8bb0 | |||
| e9ef2bc4ae | |||
| 8587574fcd | |||
| c3617fc005 | |||
| 8f81318ecf | |||
| afd2f9a299 | |||
| fff5740b14 | |||
| 7545f94bbd | |||
| edb90327e5 | |||
| fdff5092b0 | |||
| 234116163a | |||
| 2a8027deb0 | |||
| d167490c09 | |||
| e7ea700c3d | |||
| edf19c8f1e | |||
| e6feed2086 | |||
| dbd9ac6eea | |||
| 86b03a3d47 | |||
| 2c2b4edc84 | |||
| 4be2d16483 | |||
| ccd0cb4641 | |||
| 561ea346db | |||
| 13bd195b22 | |||
| a35a4ffbec | |||
| 668523aa8b | |||
| d15fb0a19b | |||
| d6502f0739 | |||
| 504b851731 | |||
| cb6a81715a | |||
| 47c133cc64 | |||
| c0d490f549 | |||
| 41ad15e20b | |||
| 0707ac2cd4 | |||
| 5ce63d937d | |||
| 50791c945d | |||
| 1fb37f2f8e | |||
| eab64b7197 | |||
| 786cd3e4c7 | |||
| c8f96b1f22 | |||
| df5f0dbf9f | |||
| dc67d7c190 | |||
| 82208c220a | |||
| 2e081e5e1e | |||
| 57b8a15642 | |||
| b03bfe94a4 | |||
| a2774eb888 | |||
| 195e701fc4 | |||
| 88a5717022 | |||
| e17d42ebe3 | |||
| ae95a00301 | |||
| faff1c1228 | |||
| e04b8d081f | |||
| efb5ac5834 | |||
| 0cf4960969 | |||
| f11bcea3a2 | |||
| d8c8ac0811 | |||
| 9fd1af2302 | |||
| e6c292ecd7 | |||
| d6b5e9a50c | |||
| 789bce5092 | |||
| f888fc1717 | |||
| 545877febb | |||
| d2dba511e2 | |||
| 84c2986c01 | |||
| 44e5cd6ef3 | |||
| 7510d0fc2b | |||
| f50426b11a | |||
| b604235c35 | |||
| 6e20d5385f | |||
| 1c046abf5f | |||
| 3718851435 | |||
| c390499394 | |||
| 980d3c19d8 |
@@ -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 }}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
|
||||
@@ -50,9 +50,6 @@ node_modules
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Typescript client
|
||||
src/frontend/tsclient
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Generated
+1515
-41
File diff suppressed because it is too large
Load Diff
@@ -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,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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { fetchUser } from './fetchUser'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
|
||||
/**
|
||||
* returns info about currently logged in user
|
||||
*
|
||||
* `isLoggedIn` is undefined while query is loading and true/false when it's done
|
||||
*/
|
||||
export const useUser = () => {
|
||||
const query = useQuery({
|
||||
queryKey: [keys.user],
|
||||
queryFn: fetchUser,
|
||||
})
|
||||
|
||||
const isLoggedIn =
|
||||
query.status === 'success' ? query.data !== false : undefined
|
||||
const isLoggedOut = isLoggedIn === false
|
||||
|
||||
return {
|
||||
...query,
|
||||
// if fetchUser returns false, it means the user is not logged in: expose that
|
||||
user: query.data === false ? undefined : query.data,
|
||||
isLoggedIn: query.data !== undefined && query.data !== false,
|
||||
user: isLoggedOut ? undefined : (query.data as ApiUser | undefined),
|
||||
isLoggedIn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { LoadingScreen } from '@/layout/LoadingScreen'
|
||||
|
||||
/**
|
||||
* wrapper that renders children only when user info has been actually fetched
|
||||
*
|
||||
* this is helpful to prevent flash of logged-out content for a few milliseconds when user is actually logged in
|
||||
*/
|
||||
export const RenderIfUserFetched = ({ children }: { children: ReactNode }) => {
|
||||
const { isLoggedIn } = useUser()
|
||||
return isLoggedIn !== undefined ? (
|
||||
children
|
||||
) : (
|
||||
<LoadingScreen renderTimeout={1000} />
|
||||
)
|
||||
}
|
||||
@@ -1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"app": "Meet",
|
||||
"backToHome": "",
|
||||
"cancel": "",
|
||||
"closeDialog": "",
|
||||
"error": {
|
||||
"heading": ""
|
||||
},
|
||||
"feedbackAlert": "",
|
||||
"forbidden": {
|
||||
"heading": ""
|
||||
},
|
||||
"languageSelector": {
|
||||
"buttonLabel": "",
|
||||
"popoverLabel": ""
|
||||
},
|
||||
"loading": "",
|
||||
"loggedInUserTooltip": "",
|
||||
"login": "Anmelden",
|
||||
"logout": "",
|
||||
"notFound": {
|
||||
"heading": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"createMeeting": "",
|
||||
"heading": "",
|
||||
"intro": "",
|
||||
"joinInputError": "",
|
||||
"joinInputExample": "",
|
||||
"joinInputLabel": "",
|
||||
"joinInputSubmit": "",
|
||||
"joinMeeting": "",
|
||||
"joinMeetingTipContent": "",
|
||||
"joinMeetingTipHeading": "",
|
||||
"loginToCreateMeeting": ""
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"feedback": {
|
||||
"body": "",
|
||||
"heading": ""
|
||||
},
|
||||
"join": {
|
||||
"camlabel": "",
|
||||
"heading": "",
|
||||
"joinLabel": "",
|
||||
"micLabel": "",
|
||||
"userLabel": ""
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
"copied": "",
|
||||
"copy": "",
|
||||
"heading": "",
|
||||
"inputLabel": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dialog": {
|
||||
"heading": ""
|
||||
},
|
||||
"language": {
|
||||
"heading": "",
|
||||
"label": ""
|
||||
},
|
||||
"settingsButtonLabel": ""
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"dialog": {
|
||||
"heading": "Settings"
|
||||
},
|
||||
"language": {
|
||||
"heading": "Language",
|
||||
"label": "Language"
|
||||
},
|
||||
"settingsButtonLabel": "Settings"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -10,7 +10,7 @@ const badge = cva({
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
small: {
|
||||
sm: {
|
||||
textStyle: 'badge',
|
||||
},
|
||||
normal: {},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user