Merge branch 'main' into nv-handle-repo-exist-error
This commit is contained in:
commit
7147611842
2
.github/workflows/lint.yaml
vendored
2
.github/workflows/lint.yaml
vendored
@ -19,5 +19,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
- run: yarn
|
- run: yarn
|
||||||
|
- name: Build libs
|
||||||
|
run: yarn workspace gql-client run build
|
||||||
- name: Linter check
|
- name: Linter check
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
39
.github/workflows/test-app-deployment.yaml
vendored
Normal file
39
.github/workflows/test-app-deployment.yaml
vendored
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
name: Test webapp deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test_app_deployment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [20.x]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn
|
||||||
|
- name: Test webapp deployment
|
||||||
|
run: ./packages/deployer/test/test-webapp-deployment-undeployment.sh
|
||||||
|
- name: Notify Vulcanize Slack on CI failure
|
||||||
|
if: ${{ always() && github.ref_name == 'main' }}
|
||||||
|
uses: ravsamhq/notify-slack-action@v2
|
||||||
|
with:
|
||||||
|
status: ${{ job.status }}
|
||||||
|
notify_when: 'failure'
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.VULCANIZE_SLACK_CI_ALERTS_WEBHOOK }}
|
||||||
|
- name: Notify DeepStack Slack on CI failure
|
||||||
|
if: ${{ always() && github.ref_name == 'main' }}
|
||||||
|
uses: ravsamhq/notify-slack-action@v2
|
||||||
|
with:
|
||||||
|
status: ${{ job.status }}
|
||||||
|
notify_when: 'failure'
|
||||||
|
env:
|
||||||
|
SLACK_WEBHOOK_URL: ${{ secrets.DEEPSTACK_SLACK_CI_ALERTS_WEBHOOK }}
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,3 +5,5 @@ yarn-error.log
|
|||||||
.yarnrc
|
.yarnrc
|
||||||
|
|
||||||
packages/backend/environments/local.toml
|
packages/backend/environments/local.toml
|
||||||
|
packages/backend/dev/
|
||||||
|
packages/frontend/dist/
|
1
.node-version
Normal file
1
.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
v20.12.1
|
143
README.md
143
README.md
@ -6,9 +6,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@github.com:snowball-tools/snowballtools-base.git
|
git clone git@github.com:snowball-tools/snowballtools-base.git
|
||||||
|
cd snowballtools-base
|
||||||
```
|
```
|
||||||
|
|
||||||
- In root of the repo, install depedencies
|
- Install dependencies in root
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn
|
yarn
|
||||||
@ -20,33 +21,21 @@
|
|||||||
yarn build --ignore frontend
|
yarn build --ignore frontend
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backend
|
Let us assume the following domains for backend and frontend
|
||||||
|
|
||||||
- Change directory to `packages/backend`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd packages/backend
|
|
||||||
```
|
|
||||||
|
|
||||||
- Rename backend config file from [environments/local.toml.example](packages/backend/environments/local.toml.example) to `local.toml`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mv environments/local.toml.example environments/local.toml
|
|
||||||
```
|
|
||||||
|
|
||||||
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
|
|
||||||
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
|
||||||
- In "Homepage URL", type `http://localhost:3000`
|
|
||||||
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
|
|
||||||
- Generate a new client secret after app is created
|
|
||||||
|
|
||||||
### Backend Production
|
|
||||||
|
|
||||||
- Let us assume the following domains for backend and frontend
|
|
||||||
- Backend server: `api.snowballtools.com`
|
- Backend server: `api.snowballtools.com`
|
||||||
- Frontend app: `dashboard.snowballtools.com`
|
- Frontend app: `dashboard.snowballtools.com`
|
||||||
|
|
||||||
- Update the following in backend [config file](packages/backend/environments/local.toml)
|
- Configuration Files
|
||||||
|
|
||||||
|
- For Backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp packages/backend/environments/local.toml.example packages/backend/environments/local.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
- Production
|
||||||
|
|
||||||
|
- Update the following in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
@ -62,25 +51,22 @@
|
|||||||
domain = ""
|
domain = ""
|
||||||
```
|
```
|
||||||
|
|
||||||
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
|
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
|
||||||
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
|
||||||
- In "Homepage URL", type `https://dashboard.snowballtools.com`
|
- Homepage URL: `https://dashboard.snowballtools.com`
|
||||||
- In "Authorization callback URL", type `https://dashboard.snowballtools.com/organization/projects/create`
|
- Authorization callback URL: `https://dashboard.snowballtools.com/organization/projects/create`
|
||||||
- Generate a new client secret after app is created
|
- Generate a new client secret after app is created
|
||||||
|
|
||||||
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
|
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
...
|
|
||||||
[gitHub]
|
[gitHub]
|
||||||
webhookUrl = "https://api.snowballtools.com"
|
webhookUrl = "https://api.snowballtools.com"
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
|
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
...
|
|
||||||
[registryConfig]
|
[registryConfig]
|
||||||
fetchDeploymentRecordDelay = 5000
|
fetchDeploymentRecordDelay = 5000
|
||||||
# Use actual port for REST endpoint
|
# Use actual port for REST endpoint
|
||||||
@ -98,24 +84,17 @@
|
|||||||
amount = "200000"
|
amount = "200000"
|
||||||
denom = "aphoton"
|
denom = "aphoton"
|
||||||
gas = "750000"
|
gas = "750000"
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Start the server in `packages/backend`
|
- Development
|
||||||
|
|
||||||
```bash
|
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
|
||||||
yarn start
|
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
|
||||||
```
|
- Homepage URL: `http://localhost:3000`
|
||||||
|
- Authorization callback URL: `http://localhost:3000/organization/projects/create`
|
||||||
### Backend Development
|
|
||||||
|
|
||||||
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
|
|
||||||
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
|
|
||||||
- In "Homepage URL", type `http://localhost:3000`
|
|
||||||
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
|
|
||||||
- Generate a new client secret after app is created
|
- Generate a new client secret after app is created
|
||||||
|
|
||||||
- Setup Laconicd
|
- Setup Laconicd
|
||||||
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
|
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
|
||||||
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
|
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
@ -148,7 +127,7 @@
|
|||||||
|
|
||||||
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
|
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
- Setup ngrok for GitHub webhooks
|
- Setup ngrok for GitHub webhooks
|
||||||
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
|
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
|
||||||
- Start ngrok and point to backend server endpoint
|
- Start ngrok and point to backend server endpoint
|
||||||
|
|
||||||
@ -159,68 +138,44 @@
|
|||||||
- Look for the forwarding URL in ngrok
|
- Look for the forwarding URL in ngrok
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
...
|
|
||||||
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
|
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
|
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
...
|
|
||||||
[gitHub]
|
[gitHub]
|
||||||
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
|
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Start the server in `packages/backend`
|
- For Frontend:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn start:dev
|
cp packages/frontend/.env.example packages/frontend/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend
|
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
|
||||||
|
|
||||||
- Change directory to `packages/frontend` in a new terminal
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd packages/frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
- Rename [.env.example](packages/frontend/.env.example) to `.env`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mv .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
REACT_APP_GITHUB_CLIENT_ID = <CLIENT_ID>
|
REACT_APP_GITHUB_CLIENT_ID = <CLIENT_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Set `REACT_APP_GITHUB_PWA_TEMPLATE_REPO` and `REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
|
- Set `REACT_APP_GITHUB_PWA_TEMPLATE_REPO` and `REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Set actual owner/name of the template repo that will be used for creating new repo
|
REACT_APP_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo
|
||||||
REACT_APP_GITHUB_PWA_TEMPLATE_REPO = cerc-io/test-progressive-web-app
|
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo
|
||||||
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = cerc-io/image-upload-pwa-example
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Production
|
- Production
|
||||||
|
|
||||||
- Let us assume the following domains for backend and frontend
|
- Set the following values in [.env](packages/frontend/.env) file
|
||||||
- Backend server: `api.snowballtools.com`
|
|
||||||
- Frontend app: `dashboard.snowballtools.com`
|
|
||||||
|
|
||||||
- Set the following values in [.env](packages/frontend/.env) file
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# Backend server endpoint
|
REACT_APP_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
|
||||||
REACT_APP_SERVER_URL = 'https://api.snowballtools.com'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
|
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
|
||||||
- Create a project and add information to use wallet connect SDK
|
- Create a project and add information to use wallet connect SDK
|
||||||
- Add project name and select project type as `App`
|
- Add project name and select project type as `App`
|
||||||
- Set project home page URL to `https://dashboard.snowballtools.com`
|
- Set project home page URL to `https://dashboard.snowballtools.com`
|
||||||
@ -230,27 +185,27 @@
|
|||||||
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
|
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Build the React application
|
- Build the React application
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
- Use a web server for hosting static built files
|
- Use a web server for hosting static built files
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m http.server -d build 3000
|
python3 -m http.server -d build 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
- Development
|
||||||
|
|
||||||
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
|
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
REACT_APP_SERVER_URL = 'http://localhost:8000'
|
REACT_APP_SERVER_URL = 'http://localhost:8000'
|
||||||
```
|
```
|
||||||
|
|
||||||
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
|
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
|
||||||
- Create a project and add information to use wallet connect SDK
|
- Create a project and add information to use wallet connect SDK
|
||||||
- Add project name and select project type as `App`
|
- Add project name and select project type as `App`
|
||||||
- Project home page URL is not required to be set
|
- Project home page URL is not required to be set
|
||||||
@ -260,10 +215,18 @@
|
|||||||
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
|
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
- Start the React application
|
- The React application will be running in `http://localhost:3000/`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Start the backend server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn start:backend
|
||||||
|
```
|
||||||
|
|
||||||
|
- Start the frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
- The React application will be running in `http://localhost:3000/`
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
PKG_DIR="./packages/frontend"
|
PKG_DIR="./packages/frontend"
|
||||||
OUTPUT_DIR="${PKG_DIR}/build"
|
OUTPUT_DIR="${PKG_DIR}/dist"
|
||||||
DEST_DIR=${1:-/data}
|
DEST_DIR=${1:-/data}
|
||||||
|
|
||||||
if [[ -d "$DEST_DIR" ]]; then
|
if [[ -d "$DEST_DIR" ]]; then
|
||||||
|
@ -4,15 +4,15 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"dependencies": {},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"depcheck": "^1.4.2",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"lerna": "^8.0.0",
|
"lerna": "^8.0.0",
|
||||||
"depcheck": "^1.4.2"
|
"patch-package": "^8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"build": "lerna run build --stream",
|
"build": "lerna run build --stream",
|
||||||
"lint": "lerna run lint --stream -- --max-warnings=0"
|
"lint": "lerna run lint --stream"
|
||||||
}
|
}
|
||||||
}
|
}
|
1
packages/backend/.node-version
Normal file
1
packages/backend/.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
v20.12.1
|
@ -3,15 +3,18 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cerc-io/laconic-sdk": "^0.1.14",
|
|
||||||
"@graphql-tools/schema": "^10.0.2",
|
"@graphql-tools/schema": "^10.0.2",
|
||||||
"@graphql-tools/utils": "^10.0.12",
|
"@graphql-tools/utils": "^10.0.12",
|
||||||
"@octokit/oauth-app": "^6.1.0",
|
"@octokit/oauth-app": "^6.1.0",
|
||||||
|
"@snowballtools/laconic-sdk": "^0.1.17",
|
||||||
|
"@turnkey/sdk-server": "^0.1.0",
|
||||||
"@types/debug": "^4.1.5",
|
"@types/debug": "^4.1.5",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.0",
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
"apollo-server-core": "^3.13.0",
|
"apollo-server-core": "^3.13.0",
|
||||||
"apollo-server-express": "^3.13.0",
|
"apollo-server-express": "^3.13.0",
|
||||||
|
"cookie-session": "^2.1.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@ -35,30 +38,20 @@
|
|||||||
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
|
"copy-assets": "copyfiles -u 1 src/**/*.gql dist/",
|
||||||
"clean": "rm -rf ./dist",
|
"clean": "rm -rf ./dist",
|
||||||
"build": "yarn clean && tsc && yarn copy-assets",
|
"build": "yarn clean && tsc && yarn copy-assets",
|
||||||
"lint": "eslint .",
|
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
|
"test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts",
|
||||||
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
|
"test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts",
|
||||||
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
|
"test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts",
|
||||||
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/cookie-session": "^2.0.49",
|
||||||
"@types/express-session": "^1.17.10",
|
"@types/express-session": "^1.17.10",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
|
||||||
"@typescript-eslint/parser": "^6.18.1",
|
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.2.2",
|
||||||
"copyfiles": "^2.4.1",
|
"copyfiles": "^2.4.1",
|
||||||
"eslint": "^8.56.0",
|
|
||||||
"eslint-config-prettier": "^9.1.0",
|
|
||||||
"eslint-config-semistandard": "^15.0.1",
|
|
||||||
"eslint-config-standard": "^16.0.3",
|
|
||||||
"eslint-plugin-import": "^2.27.5",
|
|
||||||
"eslint-plugin-node": "^11.1.0",
|
|
||||||
"eslint-plugin-prettier": "^5.1.3",
|
|
||||||
"eslint-plugin-promise": "^5.1.0",
|
|
||||||
"eslint-plugin-standard": "^5.0.0",
|
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"workspace": "^0.0.1-preview.1"
|
"workspace": "^0.0.1-preview.1"
|
||||||
}
|
}
|
||||||
|
@ -52,4 +52,10 @@ export interface Config {
|
|||||||
gitHub: GitHubConfig;
|
gitHub: GitHubConfig;
|
||||||
registryConfig: RegistryConfig;
|
registryConfig: RegistryConfig;
|
||||||
misc: MiscConfig;
|
misc: MiscConfig;
|
||||||
|
turnkey: {
|
||||||
|
apiBaseUrl: string;
|
||||||
|
apiPublicKey: string;
|
||||||
|
apiPrivateKey: string;
|
||||||
|
defaultOrganizationId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,12 @@ export class User {
|
|||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
subOrgId!: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
turnkeyWalletId!: string;
|
||||||
|
|
||||||
@OneToMany(() => ProjectMember, (projectMember) => projectMember.project, {
|
@OneToMany(() => ProjectMember, (projectMember) => projectMember.project, {
|
||||||
cascade: ['soft-remove']
|
cascade: ['soft-remove']
|
||||||
})
|
})
|
||||||
|
@ -9,8 +9,6 @@ import { Database } from './database';
|
|||||||
import { createAndStartServer } from './server';
|
import { createAndStartServer } from './server';
|
||||||
import { createResolvers } from './resolvers';
|
import { createResolvers } from './resolvers';
|
||||||
import { getConfig } from './utils';
|
import { getConfig } from './utils';
|
||||||
import { Config } from './config';
|
|
||||||
import { DEFAULT_CONFIG_FILE_PATH } from './constants';
|
|
||||||
import { Service } from './service';
|
import { Service } from './service';
|
||||||
import { Registry } from './registry';
|
import { Registry } from './registry';
|
||||||
|
|
||||||
@ -18,13 +16,12 @@ const log = debug('snowball:server');
|
|||||||
const OAUTH_CLIENT_TYPE = 'oauth-app';
|
const OAUTH_CLIENT_TYPE = 'oauth-app';
|
||||||
|
|
||||||
export const main = async (): Promise<void> => {
|
export const main = async (): Promise<void> => {
|
||||||
// TODO: get config path using cli
|
const { server, database, gitHub, registryConfig, misc } = await getConfig();
|
||||||
const { server, database, gitHub, registryConfig, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
|
|
||||||
|
|
||||||
const app = new OAuthApp({
|
const app = new OAuthApp({
|
||||||
clientType: OAUTH_CLIENT_TYPE,
|
clientType: OAUTH_CLIENT_TYPE,
|
||||||
clientId: gitHub.oAuth.clientId,
|
clientId: gitHub.oAuth.clientId,
|
||||||
clientSecret: gitHub.oAuth.clientSecret
|
clientSecret: gitHub.oAuth.clientSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = new Database(database, misc);
|
const db = new Database(database, misc);
|
||||||
@ -35,7 +32,7 @@ export const main = async (): Promise<void> => {
|
|||||||
{ gitHubConfig: gitHub, registryConfig },
|
{ gitHubConfig: gitHub, registryConfig },
|
||||||
db,
|
db,
|
||||||
app,
|
app,
|
||||||
registry
|
registry,
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeDefs = fs
|
const typeDefs = fs
|
||||||
|
@ -3,7 +3,7 @@ import assert from 'assert';
|
|||||||
import { inc as semverInc } from 'semver';
|
import { inc as semverInc } from 'semver';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import { Registry as LaconicRegistry } from '@cerc-io/laconic-sdk';
|
import { Registry as LaconicRegistry } from '@snowballtools/laconic-sdk';
|
||||||
|
|
||||||
import { RegistryConfig } from './config';
|
import { RegistryConfig } from './config';
|
||||||
import {
|
import {
|
||||||
|
@ -1,41 +1,104 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { SiweMessage, generateNonce } from 'siwe';
|
import { SiweMessage } from 'siwe';
|
||||||
|
import { Service } from '../service';
|
||||||
|
import { authenticateUser, createUser } from '../turnkey-backend';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/nonce', async (_, res) => {
|
//
|
||||||
res.send(generateNonce());
|
// Turnkey
|
||||||
|
//
|
||||||
|
router.get('/registration/:email', async (req, res) => {
|
||||||
|
const service: Service = req.app.get('service');
|
||||||
|
const user = await service.getUserByEmail(req.params.email);
|
||||||
|
if (user) {
|
||||||
|
return res.send({ subOrganizationId: user?.subOrgId });
|
||||||
|
} else {
|
||||||
|
return res.sendStatus(204);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const { email, challenge, attestation } = req.body;
|
||||||
|
const user = await createUser(req.app.get('service'), {
|
||||||
|
challenge,
|
||||||
|
attestation,
|
||||||
|
userEmail: email,
|
||||||
|
userName: email.split('@')[0],
|
||||||
|
});
|
||||||
|
req.session.userId = user.id;
|
||||||
|
res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/authenticate', async (req, res) => {
|
||||||
|
const { signedWhoamiRequest } = req.body;
|
||||||
|
const user = await authenticateUser(
|
||||||
|
req.app.get('service'),
|
||||||
|
signedWhoamiRequest,
|
||||||
|
);
|
||||||
|
if (user) {
|
||||||
|
req.session.userId = user.id;
|
||||||
|
res.sendStatus(200);
|
||||||
|
} else {
|
||||||
|
res.sendStatus(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lit
|
||||||
|
//
|
||||||
|
|
||||||
router.post('/validate', async (req, res) => {
|
router.post('/validate', async (req, res) => {
|
||||||
const { message, signature } = req.body;
|
const { message, signature, action } = req.body;
|
||||||
const { success, data } = await new SiweMessage(message).verify({
|
const { success, data } = await new SiweMessage(message).verify({
|
||||||
signature
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (!success) {
|
||||||
req.session.address = data.address;
|
return res.send({ success });
|
||||||
req.session.chainId = data.chainId;
|
}
|
||||||
|
const service: Service = req.app.get('service');
|
||||||
|
const user = await service.getUserByEthAddress(data.address);
|
||||||
|
|
||||||
|
if (action === 'signup') {
|
||||||
|
if (user) {
|
||||||
|
return res.send({ success: false, error: 'user_already_exists' });
|
||||||
|
}
|
||||||
|
const newUser = await service.createUser({
|
||||||
|
ethAddress: data.address,
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
subOrgId: '',
|
||||||
|
turnkeyWalletId: '',
|
||||||
|
});
|
||||||
|
req.session.userId = newUser.id;
|
||||||
|
} else if (action === 'login') {
|
||||||
|
if (!user) {
|
||||||
|
return res.send({ success: false, error: 'user_not_found' });
|
||||||
|
}
|
||||||
|
req.session.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.send({ success });
|
res.send({ success });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// General
|
||||||
|
//
|
||||||
router.get('/session', (req, res) => {
|
router.get('/session', (req, res) => {
|
||||||
if (req.session.address && req.session.chainId) {
|
if (req.session.userId) {
|
||||||
res.send({ address: req.session.address, chainId: req.session.chainId });
|
res.send({
|
||||||
|
userId: req.session.userId,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(401).send({ error: 'Unauthorized: No active session' });
|
res.status(401).send({ error: 'Unauthorized: No active session' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
req.session.destroy((err) => {
|
// This is how you clear cookie-session
|
||||||
if (err) {
|
(req as any).session = null;
|
||||||
return res.send({ success: false });
|
|
||||||
}
|
|
||||||
res.send({ success: true });
|
res.send({ success: true });
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@ -6,9 +6,9 @@ import { createServer } from 'http';
|
|||||||
import {
|
import {
|
||||||
ApolloServerPluginDrainHttpServer,
|
ApolloServerPluginDrainHttpServer,
|
||||||
ApolloServerPluginLandingPageLocalDefault,
|
ApolloServerPluginLandingPageLocalDefault,
|
||||||
AuthenticationError
|
AuthenticationError,
|
||||||
} from 'apollo-server-core';
|
} from 'apollo-server-core';
|
||||||
import session from 'express-session';
|
import cookieSession from 'cookie-session';
|
||||||
|
|
||||||
import { TypeSource } from '@graphql-tools/utils';
|
import { TypeSource } from '@graphql-tools/utils';
|
||||||
import { makeExecutableSchema } from '@graphql-tools/schema';
|
import { makeExecutableSchema } from '@graphql-tools/schema';
|
||||||
@ -23,8 +23,7 @@ const log = debug('snowball:server');
|
|||||||
|
|
||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
address: string;
|
userId: string;
|
||||||
chainId: number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +31,7 @@ export const createAndStartServer = async (
|
|||||||
serverConfig: ServerConfig,
|
serverConfig: ServerConfig,
|
||||||
typeDefs: TypeSource,
|
typeDefs: TypeSource,
|
||||||
resolvers: any,
|
resolvers: any,
|
||||||
service: Service
|
service: Service,
|
||||||
): Promise<ApolloServer> => {
|
): Promise<ApolloServer> => {
|
||||||
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
|
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
|
||||||
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session;
|
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session;
|
||||||
@ -45,7 +44,7 @@ export const createAndStartServer = async (
|
|||||||
// Create the schema
|
// Create the schema
|
||||||
const schema = makeExecutableSchema({
|
const schema = makeExecutableSchema({
|
||||||
typeDefs,
|
typeDefs,
|
||||||
resolvers
|
resolvers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = new ApolloServer({
|
const server = new ApolloServer({
|
||||||
@ -54,46 +53,31 @@ export const createAndStartServer = async (
|
|||||||
context: async ({ req }) => {
|
context: async ({ req }) => {
|
||||||
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
||||||
|
|
||||||
const { address } = req.session;
|
const { userId } = req.session;
|
||||||
|
|
||||||
if (!address) {
|
if (!userId) {
|
||||||
throw new AuthenticationError('Unauthorized: No active session');
|
throw new AuthenticationError('Unauthorized: No active session');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find/create user from ETH address in request session
|
const user = await service.getUser(userId);
|
||||||
const user = await service.loadOrCreateUser(address);
|
|
||||||
|
|
||||||
return { user };
|
return { user };
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
// Proper shutdown for the HTTP server
|
// Proper shutdown for the HTTP server
|
||||||
ApolloServerPluginDrainHttpServer({ httpServer }),
|
ApolloServerPluginDrainHttpServer({ httpServer }),
|
||||||
ApolloServerPluginLandingPageLocalDefault({ embed: true })
|
ApolloServerPluginLandingPageLocalDefault({ embed: true }),
|
||||||
]
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.start();
|
await server.start();
|
||||||
|
|
||||||
app.use(cors({
|
app.use(
|
||||||
|
cors({
|
||||||
origin: appOriginUrl,
|
origin: appOriginUrl,
|
||||||
credentials: true
|
credentials: true,
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
const sessionOptions: session.SessionOptions = {
|
|
||||||
secret: secret,
|
|
||||||
resave: false,
|
|
||||||
saveUninitialized: true,
|
|
||||||
cookie: {
|
|
||||||
secure: new URL(appOriginUrl).protocol === 'https:',
|
|
||||||
// TODO: Set cookie maxAge and handle cookie expiry in frontend
|
|
||||||
// maxAge: SESSION_COOKIE_MAX_AGE,
|
|
||||||
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (domain) {
|
|
||||||
sessionOptions.cookie!.domain = domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trustProxy) {
|
if (trustProxy) {
|
||||||
// trust first proxy
|
// trust first proxy
|
||||||
@ -101,7 +85,14 @@ export const createAndStartServer = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
session(sessionOptions)
|
cookieSession({
|
||||||
|
secret: secret,
|
||||||
|
secure: new URL(appOriginUrl).protocol === 'https:',
|
||||||
|
// 23 hours (less than 24 hours to avoid sessionSigs expiration issues)
|
||||||
|
maxAge: 23 * 60 * 60 * 1000,
|
||||||
|
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax',
|
||||||
|
domain: domain || undefined,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.applyMiddleware({
|
server.applyMiddleware({
|
||||||
@ -109,8 +100,8 @@ export const createAndStartServer = async (
|
|||||||
path: gqlPath,
|
path: gqlPath,
|
||||||
cors: {
|
cors: {
|
||||||
origin: [appOriginUrl],
|
origin: [appOriginUrl],
|
||||||
credentials: true
|
credentials: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
@ -161,24 +161,48 @@ export class Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
async getUserByEmail(email: string): Promise<User | null> {
|
||||||
// Get user by ETH address
|
return await this.db.getUser({
|
||||||
let user = await this.db.getUser({
|
where: {
|
||||||
|
email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBySubOrgId(subOrgId: string): Promise<User | null> {
|
||||||
|
return await this.db.getUser({
|
||||||
|
where: {
|
||||||
|
subOrgId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEthAddress (ethAddress: string): Promise<User | null> {
|
||||||
|
return await this.db.getUser({
|
||||||
where: {
|
where: {
|
||||||
ethAddress
|
ethAddress
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
async createUser (params: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
subOrgId: string
|
||||||
|
ethAddress: string
|
||||||
|
turnkeyWalletId: string
|
||||||
|
}): Promise<User> {
|
||||||
const [org] = await this.db.getOrganizations({});
|
const [org] = await this.db.getOrganizations({});
|
||||||
assert(org, 'No organizations exists in database');
|
assert(org, 'No organizations exists in database');
|
||||||
|
|
||||||
// Create user with new address
|
// Create user with new address
|
||||||
user = await this.db.addUser({
|
const user = await this.db.addUser({
|
||||||
email: `${ethAddress}@example.com`,
|
email: params.email,
|
||||||
name: ethAddress,
|
name: params.name,
|
||||||
|
subOrgId: params.subOrgId,
|
||||||
|
ethAddress: params.ethAddress,
|
||||||
isVerified: true,
|
isVerified: true,
|
||||||
ethAddress
|
turnkeyWalletId: params.turnkeyWalletId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.db.addUserOrganization({
|
await this.db.addUserOrganization({
|
||||||
@ -186,7 +210,6 @@ export class Service {
|
|||||||
organization: org,
|
organization: org,
|
||||||
role: Role.Owner
|
role: Role.Owner
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
130
packages/backend/src/turnkey-backend.ts
Normal file
130
packages/backend/src/turnkey-backend.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import { Turnkey, TurnkeyApiTypes } from '@turnkey/sdk-server';
|
||||||
|
|
||||||
|
// Default path for the first Ethereum address in a new HD wallet.
|
||||||
|
// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form:
|
||||||
|
// m / purpose' / coin_type' / account' / change / address_index
|
||||||
|
// - Purpose is a constant set to 44' following the BIP43 recommendation.
|
||||||
|
// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md
|
||||||
|
// - Account, Change, and Address Index are set to 0
|
||||||
|
import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server';
|
||||||
|
import { getConfig } from './utils';
|
||||||
|
import { Service } from './service';
|
||||||
|
|
||||||
|
type TAttestation = TurnkeyApiTypes['v1Attestation'];
|
||||||
|
|
||||||
|
type CreateUserParams = {
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
challenge: string;
|
||||||
|
attestation: TAttestation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
service: Service,
|
||||||
|
{ userName, userEmail, challenge, attestation }: CreateUserParams,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (await service.getUserByEmail(userEmail)) {
|
||||||
|
throw new Error(`User already exists: ${userEmail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const turnkey = new Turnkey(config.turnkey);
|
||||||
|
|
||||||
|
const apiClient = turnkey.api();
|
||||||
|
|
||||||
|
const walletName = `Default ETH Wallet`;
|
||||||
|
|
||||||
|
const createSubOrgResponse = await apiClient.createSubOrganization({
|
||||||
|
subOrganizationName: `Default SubOrg for ${userEmail}`,
|
||||||
|
rootQuorumThreshold: 1,
|
||||||
|
rootUsers: [
|
||||||
|
{
|
||||||
|
userName,
|
||||||
|
userEmail,
|
||||||
|
apiKeys: [],
|
||||||
|
authenticators: [
|
||||||
|
{
|
||||||
|
authenticatorName: 'Passkey',
|
||||||
|
challenge,
|
||||||
|
attestation,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wallet: {
|
||||||
|
walletName: walletName,
|
||||||
|
accounts: DEFAULT_ETHEREUM_ACCOUNTS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId);
|
||||||
|
const wallet = refineNonNull(createSubOrgResponse.wallet);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
id: wallet.walletId,
|
||||||
|
address: wallet.addresses[0],
|
||||||
|
subOrgId: subOrgId,
|
||||||
|
};
|
||||||
|
console.log('Turnkey success', result);
|
||||||
|
|
||||||
|
const user = await service.createUser({
|
||||||
|
name: userName,
|
||||||
|
email: userEmail,
|
||||||
|
subOrgId,
|
||||||
|
ethAddress: wallet.addresses[0],
|
||||||
|
turnkeyWalletId: wallet.walletId,
|
||||||
|
});
|
||||||
|
console.log('New user', user);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create user:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateUser(
|
||||||
|
service: Service,
|
||||||
|
signedWhoamiRequest: {
|
||||||
|
url: string;
|
||||||
|
body: any;
|
||||||
|
stamp: {
|
||||||
|
stampHeaderName: string;
|
||||||
|
stampHeaderValue: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tkRes = await fetch(signedWhoamiRequest.url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: signedWhoamiRequest.body,
|
||||||
|
headers: {
|
||||||
|
[signedWhoamiRequest.stamp.stampHeaderName]:
|
||||||
|
signedWhoamiRequest.stamp.stampHeaderValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('AUTH RESULT', tkRes.status);
|
||||||
|
if (tkRes.status !== 200) {
|
||||||
|
console.log(await tkRes.text());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const orgId = (await tkRes.json()).organizationId;
|
||||||
|
const user = await service.getUserBySubOrgId(orgId);
|
||||||
|
return user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to authenticate:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refineNonNull<T>(
|
||||||
|
input: T | null | undefined,
|
||||||
|
errorMessage?: string,
|
||||||
|
): T {
|
||||||
|
if (input == null) {
|
||||||
|
throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
@ -3,11 +3,18 @@ import path from 'path';
|
|||||||
import toml from 'toml';
|
import toml from 'toml';
|
||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
|
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
|
||||||
|
import { Config } from './config';
|
||||||
|
import { DEFAULT_CONFIG_FILE_PATH } from './constants';
|
||||||
|
|
||||||
const log = debug('snowball:utils');
|
const log = debug('snowball:utils');
|
||||||
|
|
||||||
export const getConfig = async <ConfigType>(
|
export async function getConfig() {
|
||||||
configFile: string
|
// TODO: get config path using cli
|
||||||
|
return await _getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
const _getConfig = async <ConfigType>(
|
||||||
|
configFile: string,
|
||||||
): Promise<ConfigType> => {
|
): Promise<ConfigType> => {
|
||||||
const configFilePath = path.resolve(configFile);
|
const configFilePath = path.resolve(configFile);
|
||||||
const fileExists = await fs.pathExists(configFilePath);
|
const fileExists = await fs.pathExists(configFilePath);
|
||||||
@ -41,7 +48,7 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
|
|||||||
entityType: EntityTarget<Entity>,
|
entityType: EntityTarget<Entity>,
|
||||||
dataSource: DataSource,
|
dataSource: DataSource,
|
||||||
entities: any,
|
entities: any,
|
||||||
relations?: any | undefined
|
relations?: any | undefined,
|
||||||
): Promise<Entity[]> => {
|
): Promise<Entity[]> => {
|
||||||
const entityRepository = dataSource.getRepository(entityType);
|
const entityRepository = dataSource.getRepository(entityType);
|
||||||
|
|
||||||
@ -56,7 +63,7 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
|
|||||||
|
|
||||||
entity = {
|
entity = {
|
||||||
...entity,
|
...entity,
|
||||||
[field]: relations[field][entityData[valueIndex]]
|
[field]: relations[field][entityData[valueIndex]],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,4 +74,5 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
|
|||||||
return savedEntity;
|
return savedEntity;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sleep = async (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
|
export const sleep = async (ms: number): Promise<void> =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import debug from 'debug';
|
import debug from 'debug';
|
||||||
|
|
||||||
import { Registry } from '@cerc-io/laconic-sdk';
|
import { Registry } from '@snowballtools/laconic-sdk';
|
||||||
|
|
||||||
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
|
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
|
||||||
import { Config } from '../src/config';
|
import { Config } from '../src/config';
|
||||||
|
@ -2,7 +2,7 @@ import debug from 'debug';
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { Registry } from '@cerc-io/laconic-sdk';
|
import { Registry } from '@snowballtools/laconic-sdk';
|
||||||
|
|
||||||
import { Config } from '../src/config';
|
import { Config } from '../src/config';
|
||||||
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
|
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
|
||||||
|
@ -30,8 +30,8 @@
|
|||||||
|
|
||||||
## Troubleshoot
|
## Troubleshoot
|
||||||
|
|
||||||
- Check deployment status [here](https://console.laconic.com/deployer).
|
- Check deployment status in [web-app deployer](https://console.laconic.com/deployer).
|
||||||
- Check records [here](https://console.laconic.com/#/registry).
|
- Check records in [registry console app](https://console.laconic.com/#/registry).
|
||||||
|
|
||||||
- If deployment fails due to low bond balance
|
- If deployment fails due to low bond balance
|
||||||
- Check balances
|
- Check balances
|
||||||
@ -40,9 +40,9 @@
|
|||||||
yarn laconic cns account get
|
yarn laconic cns account get
|
||||||
|
|
||||||
# Bond balance
|
# Bond balance
|
||||||
yarn laconic cns bond get --id 8fcf44b2f326b4b63ac57547777f1c78b7d494e5966e508f09001af53cb440ac
|
yarn laconic cns bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
|
||||||
```
|
```
|
||||||
- Command to refill bond
|
- Command to refill bond
|
||||||
```bash
|
```bash
|
||||||
yarn laconic cns bond refill --id 8fcf44b2f326b4b63ac57547777f1c78b7d494e5966e508f09001af53cb440ac --type aphoton --quantity 10000000
|
yarn laconic cns bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type aphoton --quantity 10000000
|
||||||
```
|
```
|
||||||
|
@ -13,7 +13,7 @@ PACKAGE_VERSION=$(jq -r '.version' ../frontend/package.json)
|
|||||||
CURRENT_DATE_TIME=$(date -u)
|
CURRENT_DATE_TIME=$(date -u)
|
||||||
|
|
||||||
CONFIG_FILE=config.yml
|
CONFIG_FILE=config.yml
|
||||||
REGISTRY_BOND_ID="8fcf44b2f326b4b63ac57547777f1c78b7d494e5966e508f09001af53cb440ac"
|
REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32"
|
||||||
|
|
||||||
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
||||||
|
|
||||||
|
@ -4,6 +4,6 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cerc-io/laconic-registry-cli": "^0.1.10"
|
"@snowballtools/laconic-registry-cli": "^0.1.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
record:
|
record:
|
||||||
type: ApplicationDeploymentRequest
|
type: ApplicationDeploymentRequest
|
||||||
version: '1.0.0'
|
version: '1.0.0'
|
||||||
name: snowballtools-base-frontend@0.1.7
|
name: snowballtools-base-frontend@0.1.8
|
||||||
application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.7
|
application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.8
|
||||||
dns: dashboard
|
dns: dashboard
|
||||||
config:
|
config:
|
||||||
env:
|
env:
|
||||||
@ -13,6 +13,6 @@ record:
|
|||||||
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
|
LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
|
||||||
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
|
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
|
||||||
meta:
|
meta:
|
||||||
note: Added by Snowball @ Thursday 29 February 2024 04:36:04 PM UTC
|
note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024
|
||||||
repository: "https://git.vdb.to/cerc-io/snowballtools-base"
|
repository: "https://git.vdb.to/cerc-io/snowballtools-base"
|
||||||
repository_ref: 1ff5ab3dfdba9dcf5dd1cb0f9435bd863a6d0340
|
repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
record:
|
record:
|
||||||
type: ApplicationRecord
|
type: ApplicationRecord
|
||||||
version: 0.0.1
|
version: 0.0.2
|
||||||
repository_ref: 1ff5ab3dfdba9dcf5dd1cb0f9435bd863a6d0340
|
repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e
|
||||||
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
|
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
|
||||||
app_type: webapp
|
app_type: webapp
|
||||||
name: snowballtools-base-frontend
|
name: snowballtools-base-frontend
|
||||||
app_version: 0.1.7
|
app_version: 0.1.8
|
||||||
|
23
packages/deployer/test/README.md
Normal file
23
packages/deployer/test/README.md
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# deployer test
|
||||||
|
|
||||||
|
Check if the live web app deployer is in a working state
|
||||||
|
|
||||||
|
- Web app repo used: https://github.com/snowball-tools-platform/test-progressive-web-app (main branch)
|
||||||
|
- Config used: [../config.yml](../config.yml)
|
||||||
|
- The script [test-webapp-deployment-undeployment.sh](./test-webapp-deployment-undeployment.sh) performs the following:
|
||||||
|
- Create / update [`ApplicationRecord`](./records/application-record.yml) and [`ApplicationDeploymentRequest`](./records/application-deployment-request.yml) records with latest meta data from the repo
|
||||||
|
- Fetch the latest version of `deployment-test-app` from registry and increment `ApplicationRecord` version
|
||||||
|
- Publish the resulting `ApplicationRecord` record
|
||||||
|
- Set names to the record and check name resolution
|
||||||
|
- Publish the `ApplicationDeploymentRequest` record
|
||||||
|
- Check that the deployment occurs
|
||||||
|
- Check that a `ApplicationDeploymentRecord` is created
|
||||||
|
- Check that the deployment record has correct `ApplicationRecord` id
|
||||||
|
- Check that the URL present in deployment record is active
|
||||||
|
- Create and publish a [`ApplicationDeploymentRemovalRequest`](./records/application-deployment-removal-request.yml) record
|
||||||
|
- Check that the deployment is removed
|
||||||
|
- Check that a `ApplicationDeploymentRemovalRecord` is created
|
||||||
|
- Check that the deployment URL goes down
|
||||||
|
- The test script is run in a GitHub CI [workflow](../../../.github/workflows/test-app-deployment.yaml) that:
|
||||||
|
- Is scheduled to run everyday on the default (`main`) branch or can be triggered manually
|
||||||
|
- Sends Slack alerts to configured channels on failure
|
@ -0,0 +1,4 @@
|
|||||||
|
record:
|
||||||
|
deployment: <APPLICATION_DEPLOYMENT_RECORD_ID>
|
||||||
|
type: ApplicationDeploymentRemovalRequest
|
||||||
|
version: 1.0.0
|
@ -0,0 +1,15 @@
|
|||||||
|
record:
|
||||||
|
type: ApplicationDeploymentRequest
|
||||||
|
version: '1.0.0'
|
||||||
|
name: deployment-test-app@0.1.24
|
||||||
|
application: crn://snowballtools/applications/deployment-test-app@0.1.24
|
||||||
|
dns: deployment-ci-test
|
||||||
|
config:
|
||||||
|
env:
|
||||||
|
CERC_TEST_WEBAPP_CONFIG1: "deployment test config 1"
|
||||||
|
CERC_TEST_WEBAPP_CONFIG2: "deployment test config 2"
|
||||||
|
CERC_WEBAPP_DEBUG: 0
|
||||||
|
meta:
|
||||||
|
note: Deployment test @ Thu 11 Apr 2024 07:29:19 AM UTC
|
||||||
|
repository: "https://github.com/snowball-tools-platform/test-progressive-web-app"
|
||||||
|
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26
|
8
packages/deployer/test/records/application-record.yml
Normal file
8
packages/deployer/test/records/application-record.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
record:
|
||||||
|
type: ApplicationRecord
|
||||||
|
version: 0.0.1
|
||||||
|
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26
|
||||||
|
repository: ["https://github.com/snowball-tools-platform/test-progressive-web-app"]
|
||||||
|
app_type: webapp
|
||||||
|
name: deployment-test-app
|
||||||
|
app_version: 0.1.24
|
225
packages/deployer/test/test-webapp-deployment-undeployment.sh
Executable file
225
packages/deployer/test/test-webapp-deployment-undeployment.sh
Executable file
@ -0,0 +1,225 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Repository URL
|
||||||
|
REPO_URL="https://github.com/snowball-tools-platform/test-progressive-web-app"
|
||||||
|
|
||||||
|
# Get the latest commit hash from the repository
|
||||||
|
LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}')
|
||||||
|
|
||||||
|
# Fetch the package.json file content
|
||||||
|
# Extract version from package.json content
|
||||||
|
package_json=$(wget -qO- "$REPO_URL/raw/$LATEST_HASH/package.json")
|
||||||
|
PACKAGE_VERSION=$(echo "$package_json" | jq -r '.version')
|
||||||
|
|
||||||
|
# Current date and time for note
|
||||||
|
CURRENT_DATE_TIME=$(date -u)
|
||||||
|
|
||||||
|
CONFIG_FILE=packages/deployer/config.yml
|
||||||
|
REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32"
|
||||||
|
|
||||||
|
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
|
||||||
|
|
||||||
|
APP_NAME=deployment-test-app
|
||||||
|
|
||||||
|
# Get latest version from registry and increment application-record version
|
||||||
|
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
|
||||||
|
|
||||||
|
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
|
||||||
|
# Set application-record version if no previous records were found
|
||||||
|
NEW_APPLICATION_VERSION=0.0.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate application-record.yml with incremented version
|
||||||
|
RECORD_FILE=packages/deployer/test/records/application-record.yml
|
||||||
|
|
||||||
|
cat > $RECORD_FILE <<EOF
|
||||||
|
record:
|
||||||
|
type: ApplicationRecord
|
||||||
|
version: $NEW_APPLICATION_VERSION
|
||||||
|
repository_ref: $LATEST_HASH
|
||||||
|
repository: ["$REPO_URL"]
|
||||||
|
app_type: webapp
|
||||||
|
name: $APP_NAME
|
||||||
|
app_version: $PACKAGE_VERSION
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Generate application-deployment-request.yml
|
||||||
|
REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-request.yml
|
||||||
|
|
||||||
|
cat > $REQUEST_RECORD_FILE <<EOF
|
||||||
|
record:
|
||||||
|
type: ApplicationDeploymentRequest
|
||||||
|
version: '1.0.0'
|
||||||
|
name: $APP_NAME@$PACKAGE_VERSION
|
||||||
|
application: crn://snowballtools/applications/$APP_NAME@$PACKAGE_VERSION
|
||||||
|
dns: deployment-ci-test
|
||||||
|
config:
|
||||||
|
env:
|
||||||
|
CERC_TEST_WEBAPP_CONFIG1: "deployment test config 1"
|
||||||
|
CERC_TEST_WEBAPP_CONFIG2: "deployment test config 2"
|
||||||
|
CERC_WEBAPP_DEBUG: 0
|
||||||
|
meta:
|
||||||
|
note: Deployment test @ $CURRENT_DATE_TIME
|
||||||
|
repository: "$REPO_URL"
|
||||||
|
repository_ref: $LATEST_HASH
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Record files generated successfully."
|
||||||
|
|
||||||
|
# Publish ApplicationRecord
|
||||||
|
RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
|
||||||
|
echo "ApplicationRecord published"
|
||||||
|
echo $RECORD_ID
|
||||||
|
|
||||||
|
# Set name to record
|
||||||
|
REGISTRY_APP_CRN="crn://snowballtools/applications/$APP_NAME"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
|
||||||
|
sleep 2
|
||||||
|
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
|
||||||
|
sleep 2
|
||||||
|
# Set name if latest release
|
||||||
|
yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
|
||||||
|
echo "$REGISTRY_APP_CRN set for ApplicationRecord"
|
||||||
|
|
||||||
|
# Check if record exists for REGISTRY_APP_CRN
|
||||||
|
APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN" | jq '.[0]')
|
||||||
|
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
|
||||||
|
echo "No record found for $REGISTRY_APP_CRN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REQUEST_RECORD_FILE | jq -r '.id')
|
||||||
|
echo "ApplicationDeploymentRequest published"
|
||||||
|
echo $DEPLOYMENT_REQUEST_ID
|
||||||
|
|
||||||
|
# Deployment checks
|
||||||
|
RETRY_INTERVAL=30
|
||||||
|
MAX_RETRIES=20
|
||||||
|
|
||||||
|
# Check that a ApplicationDeploymentRecord is published
|
||||||
|
retry_count=0
|
||||||
|
while true; do
|
||||||
|
deployment_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRecord --all --name "$APP_NAME" request $DEPLOYMENT_REQUEST_ID)
|
||||||
|
len_deployment_records=$(echo $deployment_records_response | jq 'length')
|
||||||
|
|
||||||
|
# Check if number of records returned is 0
|
||||||
|
if [ $len_deployment_records -eq 0 ]; then
|
||||||
|
# Check if retries are exhausted
|
||||||
|
if [ $retry_count -eq $MAX_RETRIES ]; then
|
||||||
|
echo "Retries exhausted"
|
||||||
|
echo "ApplicationDeploymentRecord for deployment request $DEPLOYMENT_REQUEST_ID not found, exiting"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRecord not found, retrying in $RETRY_INTERVAL sec..."
|
||||||
|
sleep $RETRY_INTERVAL
|
||||||
|
retry_count=$((retry_count+1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRecord found"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
DEPLOYMENT_RECORD_ID=$(echo $deployment_records_response | jq -r '.[0].id')
|
||||||
|
echo $DEPLOYMENT_RECORD_ID
|
||||||
|
|
||||||
|
# Check if ApplicationDeploymentRecord has the correct record id
|
||||||
|
fetched_application_record_id=$(echo $deployment_records_response | jq -r '.[0].attributes.application')
|
||||||
|
if [ "$fetched_application_record_id" = "$RECORD_ID" ]; then
|
||||||
|
echo "ApplicationRecord id matched"
|
||||||
|
else
|
||||||
|
echo "ApplicationRecord id does not match, expected: $RECORD_ID, received: $fetched_application_record_id"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if the url present in ApplicationDeploymentRecord is active
|
||||||
|
fetched_url=$(echo $deployment_records_response | jq -r '.[0].attributes.url')
|
||||||
|
|
||||||
|
retry_count=0
|
||||||
|
max_retries=10
|
||||||
|
retry_interval=5
|
||||||
|
while true; do
|
||||||
|
url_response=$(curl -s -o /dev/null -I -w "%{http_code}" $fetched_url)
|
||||||
|
if [ "$url_response" = "200" ]; then
|
||||||
|
echo "Deployment URL $fetched_url is active"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
if [ $retry_count -eq $max_retries ]; then
|
||||||
|
echo "Retries exhausted"
|
||||||
|
echo "Deployment URL $fetched_url is not active, exiting"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "Deployment URL $fetched_url is not active, received code $url_response, retrying in $retry_interval sec..."
|
||||||
|
sleep $retry_interval
|
||||||
|
retry_count=$((retry_count+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Generate application-deployment-removal-request.yml
|
||||||
|
REMOVAL_REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-removal-request.yml
|
||||||
|
|
||||||
|
cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
|
||||||
|
record:
|
||||||
|
deployment: $DEPLOYMENT_RECORD_ID
|
||||||
|
type: ApplicationDeploymentRemovalRequest
|
||||||
|
version: 1.0.0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
REMOVAL_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
|
||||||
|
echo "ApplicationDeploymentRemovalRequest published"
|
||||||
|
echo $REMOVAL_REQUEST_ID
|
||||||
|
|
||||||
|
# Check that an ApplicationDeploymentRemovalRecord is published
|
||||||
|
retry_count=0
|
||||||
|
while true; do
|
||||||
|
removal_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
|
||||||
|
len_removal_records=$(echo $removal_records_response | jq 'length')
|
||||||
|
|
||||||
|
# Check if number of records returned is 0
|
||||||
|
if [ $len_removal_records -eq 0 ]; then
|
||||||
|
# Check if retries are exhausted
|
||||||
|
if [ $retry_count -eq $MAX_RETRIES ]; then
|
||||||
|
echo "Retries exhausted"
|
||||||
|
echo "ApplicationDeploymentRemovalRecord for deployment removal request $REMOVAL_REQUEST_ID not found"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
|
||||||
|
sleep $RETRY_INTERVAL
|
||||||
|
retry_count=$((retry_count+1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "ApplicationDeploymentRemovalRecord found"
|
||||||
|
REMOVAL_RECORD_ID=$(echo $removal_records_response | jq -r '.[0].id')
|
||||||
|
echo $REMOVAL_RECORD_ID
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check if the application url is down after deployment removal
|
||||||
|
retry_count=0
|
||||||
|
max_retries=10
|
||||||
|
retry_interval=5
|
||||||
|
while true; do
|
||||||
|
url_response=$(curl -s -o /dev/null -I -w "%{http_code}" $fetched_url)
|
||||||
|
if [ "$url_response" = "404" ]; then
|
||||||
|
echo "Deployment URL $fetched_url is down"
|
||||||
|
break
|
||||||
|
else
|
||||||
|
if [ $retry_count -eq $max_retries ]; then
|
||||||
|
echo "Retries exhausted"
|
||||||
|
echo "Deployment URL $fetched_url is still active, exiting"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "Deployment URL $fetched_url is still active, received code $url_response, retrying in $retry_interval sec..."
|
||||||
|
sleep $retry_interval
|
||||||
|
retry_count=$((retry_count+1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Test successful"
|
@ -1,7 +1,17 @@
|
|||||||
REACT_APP_SERVER_URL = 'http://localhost:8000'
|
VITE_SERVER_URL='http://localhost:8000'
|
||||||
|
|
||||||
REACT_APP_GITHUB_CLIENT_ID =
|
VITE_GITHUB_CLIENT_ID=
|
||||||
REACT_APP_GITHUB_PWA_TEMPLATE_REPO =
|
VITE_GITHUB_PWA_TEMPLATE_REPO="snowball-tools/test-progressive-web-app"
|
||||||
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO =
|
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO="snowball-tools/image-upload-pwa-example"
|
||||||
|
|
||||||
REACT_APP_WALLET_CONNECT_ID =
|
VITE_WALLET_CONNECT_ID=
|
||||||
|
|
||||||
|
VITE_LIT_RELAY_API_KEY=
|
||||||
|
|
||||||
|
VITE_ALCHEMY_API_KEY=
|
||||||
|
|
||||||
|
VITE_BUGSNAG_API_KEY=
|
||||||
|
|
||||||
|
VITE_PASSKEY_WALLET_RPID=
|
||||||
|
VITE_TURNKEY_API_BASE_URL=
|
||||||
|
VITE_TURNKEY_ORGANIZATION_ID=
|
@ -1 +0,0 @@
|
|||||||
build
|
|
19
packages/frontend/.eslintrc.cjs
Normal file
19
packages/frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
'plugin:storybook/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaFeatures": {
|
|
||||||
"jsx": true
|
|
||||||
},
|
|
||||||
"ecmaVersion": 13,
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es2021": true
|
|
||||||
},
|
|
||||||
"plugins": ["react", "@typescript-eslint"],
|
|
||||||
"extends": [
|
|
||||||
"plugin:react/recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:prettier/recommended"
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"react": {
|
|
||||||
"version": "detect"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
1
packages/frontend/.gitignore
vendored
1
packages/frontend/.gitignore
vendored
@ -22,3 +22,4 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
*storybook.log
|
1
packages/frontend/.node-version
Normal file
1
packages/frontend/.node-version
Normal file
@ -0,0 +1 @@
|
|||||||
|
v20.12.1
|
@ -1,3 +0,0 @@
|
|||||||
# artifacts
|
|
||||||
build
|
|
||||||
coverage
|
|
31
packages/frontend/.storybook/main.ts
Normal file
31
packages/frontend/.storybook/main.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is used to resolve the absolute path of a package.
|
||||||
|
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||||
|
*/
|
||||||
|
function getAbsolutePath(value: string): any {
|
||||||
|
return dirname(require.resolve(join(value, 'package.json')));
|
||||||
|
}
|
||||||
|
const config: StorybookConfig = {
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
getAbsolutePath('@storybook/addon-onboarding'),
|
||||||
|
getAbsolutePath('@storybook/addon-links'),
|
||||||
|
getAbsolutePath('@storybook/addon-essentials'),
|
||||||
|
getAbsolutePath('@chromatic-com/storybook'),
|
||||||
|
getAbsolutePath('@storybook/addon-interactions'),
|
||||||
|
getAbsolutePath('storybook-addon-remix-react-router'),
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: getAbsolutePath('@storybook/react-vite'),
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
16
packages/frontend/.storybook/preview.ts
Normal file
16
packages/frontend/.storybook/preview.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { Preview } from '@storybook/react';
|
||||||
|
|
||||||
|
import '../src/index.css';
|
||||||
|
|
||||||
|
const preview: Preview = {
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
matchers: {
|
||||||
|
color: /(background|color)$/i,
|
||||||
|
date: /Date$/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default preview;
|
@ -1,46 +1,30 @@
|
|||||||
# Getting Started with Create React App
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using [typescript-tailwindcss-eslint-prettier](https://github.com/cufarvid/cra-templates) template.
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
## Available Scripts
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
In the project directory, you can run:
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
### `yarn start`
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
|
||||||
|
|
||||||
The page will reload if you make edits.\
|
- Configure the top-level `parserOptions` property like this:
|
||||||
You will also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `yarn test`
|
```js
|
||||||
|
export default {
|
||||||
|
// other rules...
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||||
### `yarn build`
|
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
|
||||||
Your app is ready to be deployed!
|
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
|
||||||
|
|
||||||
### `yarn eject`
|
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
|
||||||
|
|
||||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
|
||||||
|
|
||||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
|
||||||
|
|
||||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
|
||||||
|
4
packages/frontend/chromatic.config.json
Normal file
4
packages/frontend/chromatic.config.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"projectId": "Project:663d04870db27ed66a48e466",
|
||||||
|
"zip": true
|
||||||
|
}
|
22
packages/frontend/index.html
Normal file
22
packages/frontend/index.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="snowball tools dashboard" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" href="/logo192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
|
<meta name="msapplication-TileColor" content="#2d89ef" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>Snowball</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -1,7 +0,0 @@
|
|||||||
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="500" height="500" fill="#0F86F5"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M191.873 125.126C224.893 126.765 250.458 150.121 274.042 172.995C297.925 196.158 323.089 221.108 324.868 254.114C326.718 288.42 308.902 321.108 283.281 344.355C258.67 366.687 225.288 373.859 191.873 374.788C157.228 375.752 119.038 374.394 95.1648 349.588C71.6207 325.125 74.6696 287.843 75.7341 254.114C76.7518 221.865 79.2961 188.525 101.009 164.41C123.845 139.047 157.543 123.423 191.873 125.126Z" fill="#4BA4F7"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M229.373 125.126C262.393 126.765 287.958 150.121 311.542 172.995C335.425 196.158 360.589 221.108 362.368 254.114C364.218 288.42 346.402 321.108 320.781 344.355C296.17 366.687 262.788 373.859 229.373 374.788C194.728 375.752 156.538 374.394 132.665 349.588C109.121 325.125 112.17 287.843 113.234 254.114C114.252 221.865 116.796 188.525 138.509 164.41C161.345 139.047 195.043 123.423 229.373 125.126Z" fill="#8AC4FA"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M266.873 125.126C299.893 126.765 325.458 150.121 349.042 172.995C372.925 196.158 398.089 221.108 399.868 254.114C401.718 288.42 383.902 321.108 358.281 344.355C333.67 366.687 300.288 373.859 266.873 374.788C232.228 375.752 194.038 374.394 170.165 349.588C146.621 325.125 149.67 287.843 150.734 254.114C151.752 221.865 154.296 188.525 176.009 164.41C198.845 139.047 232.543 123.423 266.873 125.126Z" fill="#CAE4FD"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M304.373 125.126C337.393 126.765 362.958 150.121 386.542 172.995C410.425 196.158 435.589 221.108 437.368 254.114C439.218 288.42 421.402 321.108 395.781 344.355C371.17 366.687 337.788 373.859 304.373 374.788C269.728 375.752 231.538 374.394 207.665 349.588C184.121 325.125 187.17 287.843 188.234 254.114C189.252 221.865 191.796 188.525 213.509 164.41C236.345 139.047 270.043 123.423 304.373 125.126Z" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 2.0 KiB |
@ -1,27 +1,48 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.7",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --port 3000",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "tsc --noEmit",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"storybook": "storybook dev -p 6006",
|
||||||
|
"build-storybook": "storybook build"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@bugsnag/browser-performance": "^2.4.1",
|
||||||
|
"@bugsnag/js": "^7.22.7",
|
||||||
|
"@bugsnag/plugin-react": "^7.22.7",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.0.19",
|
"@fontsource-variable/jetbrains-mono": "^5.0.19",
|
||||||
"@fontsource/inter": "^5.0.16",
|
"@fontsource/inter": "^5.0.16",
|
||||||
"@material-tailwind/react": "^2.1.7",
|
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@snowballtools/auth": "^0.2.0",
|
||||||
|
"@snowballtools/auth-lit": "^0.2.0",
|
||||||
|
"@snowballtools/js-sdk": "^0.1.1",
|
||||||
|
"@snowballtools/link-lit-alchemy-light": "^0.2.0",
|
||||||
|
"@snowballtools/material-tailwind-react-fork": "^2.1.10",
|
||||||
|
"@snowballtools/smartwallet-alchemy-light": "^0.2.0",
|
||||||
|
"@snowballtools/types": "^0.2.0",
|
||||||
|
"@snowballtools/utils": "^0.1.1",
|
||||||
"@tanstack/react-query": "^5.22.2",
|
"@tanstack/react-query": "^5.22.2",
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/jest": "^27.5.2",
|
"@turnkey/http": "^2.10.0",
|
||||||
"@types/node": "^16.18.68",
|
"@turnkey/sdk-react": "^0.1.0",
|
||||||
"@types/react": "^18.2.42",
|
"@turnkey/webauthn-stamper": "^0.5.0",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@walletconnect/ethereum-provider": "^2.12.2",
|
||||||
"@web3modal/siwe": "^4.0.5",
|
"@web3modal/siwe": "^4.0.5",
|
||||||
"@web3modal/wagmi": "^4.0.5",
|
"@web3modal/wagmi": "^4.0.5",
|
||||||
"assert": "^2.1.0",
|
"assert": "^2.1.0",
|
||||||
@ -29,8 +50,9 @@
|
|||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"downshift": "^8.3.2",
|
"downshift": "^8.3.2",
|
||||||
"eslint-config-react-app": "^7.0.1",
|
"framer-motion": "^11.0.8",
|
||||||
"gql-client": "^1.0.0",
|
"gql-client": "^1.0.0",
|
||||||
|
"lottie-react": "^2.4.0",
|
||||||
"luxon": "^3.4.4",
|
"luxon": "^3.4.4",
|
||||||
"octokit": "^3.1.2",
|
"octokit": "^3.1.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@ -43,54 +65,41 @@
|
|||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-oauth-popup": "^1.0.5",
|
"react-oauth-popup": "^1.0.5",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
"react-scripts": "5.0.1",
|
|
||||||
"react-timer-hook": "^3.0.7",
|
"react-timer-hook": "^3.0.7",
|
||||||
"siwe": "^2.1.4",
|
"siwe": "^2.1.4",
|
||||||
"tailwind-variants": "^0.2.0",
|
"tailwind-variants": "^0.2.0",
|
||||||
"typescript": "^4.9.5",
|
"usehooks-ts": "^2.15.1",
|
||||||
"usehooks-ts": "^2.10.0",
|
"uuid": "^9.0.1",
|
||||||
"vertical-stepper-nav": "^1.0.2",
|
|
||||||
"viem": "^2.7.11",
|
"viem": "^2.7.11",
|
||||||
"wagmi": "^2.5.7",
|
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
|
||||||
"start": "react-scripts start",
|
|
||||||
"build": "react-scripts build",
|
|
||||||
"test": "react-scripts test",
|
|
||||||
"eject": "react-scripts eject",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"format:check": "prettier --check .",
|
|
||||||
"lint": "eslint ."
|
|
||||||
},
|
|
||||||
"eslintConfig": {
|
|
||||||
"extends": [
|
|
||||||
"react-app",
|
|
||||||
"react-app/jest"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"browserslist": {
|
|
||||||
"production": [
|
|
||||||
">0.2%",
|
|
||||||
"not dead",
|
|
||||||
"not op_mini all"
|
|
||||||
],
|
|
||||||
"development": [
|
|
||||||
"last 1 chrome version",
|
|
||||||
"last 1 firefox version",
|
|
||||||
"last 1 safari version"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@chromatic-com/storybook": "^1.3.3",
|
||||||
|
"@storybook/addon-essentials": "^8.0.10",
|
||||||
|
"@storybook/addon-interactions": "^8.0.10",
|
||||||
|
"@storybook/addon-links": "^8.0.10",
|
||||||
|
"@storybook/addon-onboarding": "^8.0.10",
|
||||||
|
"@storybook/blocks": "^8.0.10",
|
||||||
|
"@storybook/react": "^8.0.10",
|
||||||
|
"@storybook/react-vite": "^8.0.10",
|
||||||
|
"@storybook/test": "^8.0.10",
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
|
"@types/lodash": "^4.17.0",
|
||||||
"@types/luxon": "^3.3.7",
|
"@types/luxon": "^3.3.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
"@types/node": "^16.18.68",
|
||||||
"@typescript-eslint/parser": "^6.13.2",
|
"@types/react": "^18.2.66",
|
||||||
"eslint": "^8.55.0",
|
"@types/react-dom": "^18.2.22",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"@types/uuid": "^9.0.8",
|
||||||
"eslint-plugin-prettier": "^5.0.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"autoprefixer": "^10.4.19",
|
||||||
|
"chromatic": "^11.3.2",
|
||||||
|
"eslint-plugin-storybook": "^0.8.0",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.1.0",
|
"prettier": "^3.1.0",
|
||||||
"tailwindcss": "^3.4.1"
|
"storybook": "^8.0.10",
|
||||||
|
"storybook-addon-remix-react-router": "^3.0.0",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
6
packages/frontend/postcss.config.js
Normal file
6
packages/frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
3
packages/frontend/public/dot-border-line.svg
Normal file
3
packages/frontend/public/dot-border-line.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="197" height="2" viewBox="0 0 197 2" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="0.5" y1="1.19141" x2="197" y2="1.19141" stroke="#94A7B8" stroke-dasharray="1 12"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 196 B |
1838
packages/frontend/public/lottie/logo.json
Normal file
1838
packages/frontend/public/lottie/logo.json
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/frontend/reload-dev.sh
Executable file
9
packages/frontend/reload-dev.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
(cd /Users/rabbit-m2/p/snowball/js-sdk && NO_CLEAN=1 turbo build)
|
||||||
|
|
||||||
|
(cd ../.. && ./scripts/yarn-file-for-local-dev.sh)
|
||||||
|
|
||||||
|
rm -rf node_modules/.vite
|
||||||
|
|
||||||
|
yarn dev
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
|
||||||
import Projects from './pages/org-slug';
|
import Projects from './pages/org-slug';
|
||||||
@ -9,8 +8,10 @@ import {
|
|||||||
} from './pages/org-slug/projects/routes';
|
} from './pages/org-slug/projects/routes';
|
||||||
import ProjectSearchLayout from './layouts/ProjectSearch';
|
import ProjectSearchLayout from './layouts/ProjectSearch';
|
||||||
import Index from './pages';
|
import Index from './pages';
|
||||||
import Login from './pages/Login';
|
import AuthPage from './pages/AuthPage';
|
||||||
import { DashboardLayout } from 'pages/org-slug/layout';
|
import { DashboardLayout } from './pages/org-slug/layout';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import Web3Provider from 'context/Web3Provider';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@ -46,12 +47,36 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: <Login />,
|
element: <AuthPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/signup',
|
||||||
|
element: <AuthPage />,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return <RouterProvider router={router} />;
|
// Hacky way of checking session
|
||||||
|
// TODO: Handle redirect backs
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${import.meta.env.VITE_SERVER_URL}/auth/session`, {
|
||||||
|
credentials: 'include',
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.status !== 200) {
|
||||||
|
localStorage.clear();
|
||||||
|
const path = window.location.pathname;
|
||||||
|
if (path !== '/login' && path !== '/signup') {
|
||||||
|
window.location.pathname = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Web3Provider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</Web3Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
@ -3,14 +3,14 @@ export default [
|
|||||||
id: '1',
|
id: '1',
|
||||||
name: 'Progressive Web App (PWA)',
|
name: 'Progressive Web App (PWA)',
|
||||||
icon: 'pwa',
|
icon: 'pwa',
|
||||||
repoFullName: `${process.env.REACT_APP_GITHUB_PWA_TEMPLATE_REPO}`,
|
repoFullName: `${import.meta.env.VITE_GITHUB_PWA_TEMPLATE_REPO}`,
|
||||||
isComingSoon: false,
|
isComingSoon: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Image Upload PWA',
|
name: 'Image Upload PWA',
|
||||||
icon: 'pwa',
|
icon: 'pwa',
|
||||||
repoFullName: `${process.env.REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`,
|
repoFullName: `${import.meta.env.VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`,
|
||||||
isComingSoon: false,
|
isComingSoon: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
219
packages/frontend/src/components/CloudyFlow.tsx
Normal file
219
packages/frontend/src/components/CloudyFlow.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = React.PropsWithChildren<{
|
||||||
|
className?: string;
|
||||||
|
snowZIndex?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const CloudyFlow = ({ className, children, snowZIndex }: Props) => {
|
||||||
|
return (
|
||||||
|
<div className={`bg-sky-100 relative ${className || ''}`}>
|
||||||
|
{children}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={{ zIndex: snowZIndex || 0 }}
|
||||||
|
>
|
||||||
|
<div className="w-[3.72px] h-[3.72px] left-[587px] top-[147px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.72px] h-[4.72px] left-[742px] top-[336px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.49px] h-[3.49px] left-[36px] top-[68px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.25px] h-[3.25px] left-[55px] top-[114px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.60px] h-[5.60px] left-[1334px] top-[63px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.53px] h-[3.53px] left-[988px] top-[108px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.65px] h-[2.65px] left-[1380px] top-[16px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.60px] h-[3.60px] left-[1284px] top-[95px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-0.5 h-0.5 left-[1191px] top-[376px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[2.83px] h-[2.83px] left-[1182px] top-[257px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.41px] h-[2.41px] left-[627px] top-[26px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.71px] h-[5.71px] left-[30px] top-[33px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.09px] h-[4.09px] left-[425px] top-[386px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.38px] h-[3.38px] left-[394px] top-[29px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.70px] h-[4.70px] left-[817px] top-[113px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-1.5 h-1.5 left-[1194px] top-[332px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.89px] h-[4.89px] left-[811px] top-[76px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.25px] h-[4.25px] left-[458px] top-[366px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.82px] h-[4.82px] left-[936px] top-[46px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.74px] h-[3.74px] left-[64px] top-[132px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-1 h-1 left-[763px] top-[10px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.67px] h-[3.67px] left-[861px] top-[106px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.62px] h-[3.62px] left-[710px] top-[278px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.45px] h-[3.45px] left-[1069px] top-[329px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.92px] h-[2.92px] left-[1286px] top-[299px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.84px] h-[4.84px] left-[219px] top-[269px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.39px] h-[2.39px] left-[817px] top-[121px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.83px] h-[5.83px] left-[168px] top-[320px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.94px] h-[5.94px] left-[419px] top-[244px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.67px] h-[4.67px] left-[604px] top-[309px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.87px] h-[5.87px] left-[1098px] top-[379px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.85px] h-[5.85px] left-[644px] top-[352px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.19px] h-[4.19px] left-[1361px] top-[349px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.84px] h-[2.84px] left-[1299px] top-[194px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.51px] h-[4.51px] left-[468px] top-[319px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.73px] h-[2.73px] left-[1084px] top-[86px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.43px] h-[3.43px] left-[1271px] top-[28px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.25px] h-[2.25px] left-[106px] top-[197px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.82px] h-[2.82px] left-[122px] top-[173px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.89px] h-[2.89px] left-[343px] top-[345px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.82px] h-[2.82px] left-[433px] top-[40px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.11px] h-[4.11px] left-[904px] top-[350px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.42px] h-[4.42px] left-[1066px] top-[349px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.67px] h-[4.67px] left-[904px] top-[317px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.54px] h-[5.54px] left-[501px] top-[336px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.11px] h-[4.11px] left-[1149px] top-[206px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.55px] h-[3.55px] left-[235px] top-[362px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.60px] h-[2.60px] left-[1246px] top-[1px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.94px] h-[2.94px] left-[788px] top-[6px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.19px] h-[4.19px] left-[527px] top-[365px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.13px] h-[4.13px] left-[201px] top-[53px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.94px] h-[2.94px] left-[765px] top-[13px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.11px] h-[4.11px] left-[1254px] top-[30px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.85px] h-[3.85px] left-[107px] top-[316px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.72px] h-[5.72px] left-[1305px] top-[8px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.46px] h-[5.46px] left-[102px] top-[316px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.77px] h-[3.77px] left-[1322px] top-[334px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.84px] h-[4.84px] left-[1370px] top-[317px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.55px] h-[5.55px] left-[945px] top-[258px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.24px] h-[2.24px] left-[266px] top-[362px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.89px] h-[2.89px] left-[987px] top-[156px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.46px] h-[3.46px] left-[10px] top-[168px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.67px] h-[5.67px] left-[441px] top-[291px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.07px] h-[4.07px] left-[962px] top-[364px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.57px] h-[5.57px] left-[599px] top-[293px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.41px] h-[4.41px] left-[358px] top-[163px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.31px] h-[2.31px] left-[670px] top-[182px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.60px] h-[2.60px] left-[621px] top-[257px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[2.16px] h-[2.16px] left-[48px] top-[322px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.91px] h-[5.91px] left-[491px] top-[5px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.50px] h-[5.50px] left-[1139px] top-[274px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.74px] h-[3.74px] left-[24px] top-[177px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.57px] h-[5.57px] left-[1166px] top-[316px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5px] h-[5px] left-[445px] top-[326px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.01px] h-[3.01px] left-[438px] top-[252px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.14px] h-[4.14px] left-[554px] top-[131px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.30px] h-[5.30px] left-[1010px] top-[116px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.53px] h-[5.53px] left-[437px] top-[367px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.87px] h-[5.87px] left-[948px] top-[27px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.87px] h-[2.87px] left-[826px] top-[20px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.89px] h-[3.89px] left-[1222px] top-[112px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.77px] h-[3.77px] left-[796px] top-[395px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.09px] h-[2.09px] left-[272px] top-[103px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.12px] h-[4.12px] left-[76px] top-[2px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.51px] h-[3.51px] left-[226px] top-[276px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.03px] h-[3.03px] left-[723px] top-[197px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.14px] h-[2.14px] left-[1259px] top-[17px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.28px] h-[3.28px] left-[1244px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.45px] h-[4.45px] left-[118px] top-[128px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.15px] h-[4.15px] left-[490px] top-[204px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[4.93px] h-[4.93px] left-[552px] top-[38px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.56px] h-[5.56px] left-[115px] top-[303px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.35px] h-[2.35px] left-[509px] top-[278px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.24px] h-[5.24px] left-[804px] top-[389px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.44px] h-[2.44px] left-[1013px] top-[50px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.69px] h-[3.69px] left-[1183px] top-[95px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.83px] h-[2.83px] left-[278px] top-[181px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.22px] h-[3.22px] left-[1316px] top-[282px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.55px] h-[3.55px] left-[736px] top-[119px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.29px] h-[2.29px] left-[483px] top-[319px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.14px] h-[2.14px] left-[1135px] top-[19px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.64px] h-[3.64px] left-[39px] top-[126px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.30px] h-[5.30px] left-[237px] top-[369px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.57px] h-[5.57px] left-[1156px] top-[126px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.78px] h-[2.78px] left-[1295px] top-[74px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-0.5 h-0.5 left-[76px] top-[227px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.61px] h-[3.61px] left-[108px] top-[89px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.37px] h-[5.37px] left-[191px] top-[167px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.18px] h-[4.18px] left-[164px] top-[117px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.15px] h-[5.15px] left-[533px] top-[261px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-1.5 h-1.5 left-[327px] top-[157px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.74px] h-[5.74px] left-[1242px] top-[122px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.22px] h-[4.22px] left-[129px] top-[265px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.30px] h-[2.30px] left-[1305px] top-[86px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[2.70px] h-[2.70px] left-[1235px] top-[120px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[2.15px] h-[2.15px] left-[596px] top-[103px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.17px] h-[2.17px] left-[483px] top-[233px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.09px] h-[5.09px] left-[706px] top-[188px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.15px] h-[4.15px] left-[141px] top-[2px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.20px] h-[4.20px] left-[48px] top-[124px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.51px] h-[3.51px] left-[1095px] top-[201px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.21px] h-[3.21px] left-[730px] top-[185px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.61px] h-[2.61px] left-[722px] top-[319px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.28px] h-[2.28px] left-[444px] top-[26px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.49px] h-[4.49px] left-[355px] top-[212px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.69px] h-[3.69px] left-[1280px] top-[312px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.23px] h-[4.23px] left-[1114px] top-[113px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.48px] h-[3.48px] left-[729px] top-[117px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.11px] h-[4.11px] left-[647px] top-[276px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.16px] h-[4.16px] left-[365px] top-[116px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.35px] h-[5.35px] left-[94px] top-[194px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.84px] h-[5.84px] left-[2px] top-[84px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.43px] h-[4.43px] left-[1382px] top-[23px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.38px] h-[5.38px] left-[857px] top-[284px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[2.77px] h-[2.77px] left-[1228px] top-[385px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.65px] h-[4.65px] left-[165px] top-[184px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.53px] h-[5.53px] left-[568px] top-[354px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.59px] h-[3.59px] left-[1303px] top-[371px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.84px] h-[5.84px] left-[235px] top-[188px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.84px] h-[3.84px] left-[902px] top-[211px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.45px] h-[3.45px] left-[367px] top-[161px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.08px] h-[4.08px] left-[855px] top-[394px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.25px] h-[3.25px] left-[383px] top-[47px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.39px] h-[4.39px] left-[1313px] top-[165px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.60px] h-[5.60px] left-[697px] top-[327px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.09px] h-[2.09px] left-[646px] top-[370px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.13px] h-[3.13px] left-[728px] top-[122px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.53px] h-[5.53px] left-[203px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.83px] h-[5.83px] left-[424px] top-[121px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.82px] h-[4.82px] left-[1358px] top-[176px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.18px] h-[3.18px] left-[1212px] top-[24px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.23px] h-[5.23px] left-[260px] top-[217px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.29px] h-[5.29px] left-[1204px] top-[367px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.47px] h-[3.47px] left-[1163px] top-[159px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.77px] h-[5.77px] left-[1257px] top-[115px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.31px] h-[5.31px] left-[222px] top-[356px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.43px] h-[5.43px] left-[1141px] top-[349px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[5.62px] h-[5.62px] left-[683px] top-[81px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.91px] h-[3.91px] left-[269px] top-[3px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.51px] h-[3.51px] left-[305px] top-[310px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.41px] h-[5.41px] left-[530px] top-[94px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.64px] h-[4.64px] left-[730px] top-[301px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.59px] h-[3.59px] left-[716px] top-[14px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.77px] h-[4.77px] left-[544px] top-[13px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.29px] h-[2.29px] left-[357px] top-[281px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.42px] h-[2.42px] left-[1346px] top-[112px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.42px] h-[3.42px] left-[671px] top-[150px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.40px] h-[4.40px] left-[1324px] top-[268px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.21px] h-[5.21px] left-[1028px] top-[376px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.27px] h-[4.27px] left-[499px] top-[50px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[4.35px] h-[4.35px] left-[543px] top-[359px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.25px] h-[5.25px] left-[1245px] top-[296px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.52px] h-[5.52px] left-[360px] top-[98px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.46px] h-[4.46px] left-[741px] top-[358px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[3.90px] h-[3.90px] left-[1262px] top-[184px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[5.75px] h-[5.75px] left-[552px] top-[335px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[4.95px] h-[4.95px] left-[120px] top-[178px] absolute bg-white rounded-full" />
|
||||||
|
<div className="w-[3.28px] h-[3.28px] left-[1337px] top-[293px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.43px] h-[2.43px] left-[233px] top-[310px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-1 h-1 left-[218px] top-[322px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.68px] h-[3.68px] left-[984px] top-[8px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[2.44px] h-[2.44px] left-[832px] top-[55px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.93px] h-[3.93px] left-[1105px] top-[209px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.08px] h-[4.08px] left-[957px] top-[23px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[2.33px] h-[2.33px] left-[1066px] top-[390px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
<div className="w-[3.25px] h-[3.25px] left-[737px] top-[118px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.18px] h-[5.18px] left-[202px] top-[19px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.05px] h-[5.05px] left-[466px] top-[17px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.85px] h-[3.85px] left-[144px] top-[153px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.35px] h-[5.35px] left-[233px] top-[330px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-1 h-1 left-[730px] top-[179px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[4.46px] h-[4.46px] left-[1156px] top-[342px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.22px] h-[5.22px] left-[1275px] top-[204px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.50px] h-[5.50px] left-[38px] top-[343px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.14px] h-[5.14px] left-[867px] top-[113px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[2.19px] h-[2.19px] left-[1277px] top-[314px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[3.74px] h-[3.74px] left-[1136px] top-[197px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.37px] h-[5.37px] left-[34px] top-[226px] absolute bg-white bg-opacity-60 rounded-full" />
|
||||||
|
<div className="w-[5.93px] h-[5.93px] left-[727px] top-[272px] absolute bg-white bg-opacity-50 rounded-full" />
|
||||||
|
<div className="w-[5.29px] h-[5.29px] left-[277px] top-[43px] absolute bg-white bg-opacity-80 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
DayPicker,
|
DayPicker,
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverHandler,
|
PopoverHandler,
|
||||||
} from '@material-tailwind/react';
|
} from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
import HorizontalLine from './HorizontalLine';
|
import HorizontalLine from './HorizontalLine';
|
||||||
|
|
||||||
@ -120,14 +120,11 @@ const DatePicker = ({
|
|||||||
handler={(value) => setIsOpen(value)}
|
handler={(value) => setIsOpen(value)}
|
||||||
>
|
>
|
||||||
<PopoverHandler>
|
<PopoverHandler>
|
||||||
<Input
|
<Input onChange={() => null} value={inputValue} />
|
||||||
onChange={() => null}
|
|
||||||
value={inputValue}
|
|
||||||
crossOrigin={undefined}
|
|
||||||
/>
|
|
||||||
</PopoverHandler>
|
</PopoverHandler>
|
||||||
{/* TODO: Figure out what placeholder is for */}
|
{/* TODO: Figure out what placeholder is for */}
|
||||||
<PopoverContent placeholder={''}>
|
{/* @ts-ignore */}
|
||||||
|
<PopoverContent>
|
||||||
{mode === 'single' && (
|
{mode === 'single' && (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
mode="single"
|
mode="single"
|
||||||
@ -152,7 +149,6 @@ const DatePicker = ({
|
|||||||
className="rounded-full mr-2"
|
className="rounded-full mr-2"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@ -162,7 +158,6 @@ const DatePicker = ({
|
|||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
color="gray"
|
color="gray"
|
||||||
onClick={() => handleRangeSelect()}
|
onClick={() => handleRangeSelect()}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
Select
|
Select
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
import {
|
||||||
default as ReactDropdown,
|
default as ReactDropdown,
|
||||||
Option as ReactDropdownOption,
|
Option as ReactDropdownOption,
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
import React from 'react';
|
import { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
|
||||||
const FormatMillisecond = ({ time }: { time: number }) => {
|
export interface FormatMilliSecondProps
|
||||||
|
extends ComponentPropsWithoutRef<'div'> {
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormatMillisecond = ({ time, ...props }: FormatMilliSecondProps) => {
|
||||||
const formatTime = Duration.fromMillis(time)
|
const formatTime = Duration.fromMillis(time)
|
||||||
.shiftTo('days', 'hours', 'minutes', 'seconds')
|
.shiftTo('days', 'hours', 'minutes', 'seconds')
|
||||||
.toObject();
|
.toObject();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn('text-sm text-elements-mid-em', props?.className)}
|
||||||
|
>
|
||||||
{formatTime.days !== 0 && <span>{formatTime.days}d </span>}
|
{formatTime.days !== 0 && <span>{formatTime.days}d </span>}
|
||||||
{formatTime.hours !== 0 && <span>{formatTime.hours}h </span>}
|
{formatTime.hours !== 0 && <span>{formatTime.hours}h </span>}
|
||||||
{formatTime.minutes !== 0 && <span>{formatTime.minutes}m </span>}
|
{formatTime.minutes !== 0 && <span>{formatTime.minutes}m </span>}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const HorizontalLine = () => {
|
const HorizontalLine = () => {
|
||||||
return <hr className="h-px bg-gray-100 border-0" />;
|
return <hr className="h-px bg-gray-100 border-0" />;
|
||||||
};
|
};
|
||||||
|
23
packages/frontend/src/components/Logo.tsx
Normal file
23
packages/frontend/src/components/Logo.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Heading } from './shared/Heading';
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
orgSlug?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Logo = ({ orgSlug }: LogoProps) => {
|
||||||
|
return (
|
||||||
|
<Link to={`/${orgSlug}`}>
|
||||||
|
<div className="flex items-center gap-3 px-0 lg:px-2">
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Snowball Logo"
|
||||||
|
className="lg:h-10 lg:w-10 h-8 w-8 rounded-lg"
|
||||||
|
/>
|
||||||
|
<Heading className="lg:text-[24px] text-[19px] font-semibold">
|
||||||
|
Snowball
|
||||||
|
</Heading>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
@ -15,7 +15,8 @@ const SearchBar: React.ForwardRefRenderFunction<
|
|||||||
value={value}
|
value={value}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
appearance={'borderless'}
|
appearance="borderless"
|
||||||
|
className="w-full lg:w-[459px]"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import { StepperNav } from './VerticalStepper';
|
||||||
import { StepperNav } from 'vertical-stepper-nav';
|
|
||||||
|
|
||||||
const COLOR_COMPLETED = '#059669';
|
const COLOR_COMPLETED = '#059669';
|
||||||
const COLOR_ACTIVE = '#CFE6FC';
|
const COLOR_ACTIVE = '#CFE6FC';
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useStopwatch } from 'react-timer-hook';
|
import { useStopwatch } from 'react-timer-hook';
|
||||||
|
|
||||||
import FormatMillisecond from './FormatMilliSecond';
|
import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond';
|
||||||
|
|
||||||
const setStopWatchOffset = (time: string) => {
|
const setStopWatchOffset = (time: string) => {
|
||||||
const providedTime = new Date(time);
|
const providedTime = new Date(time);
|
||||||
@ -11,13 +10,17 @@ const setStopWatchOffset = (time: string) => {
|
|||||||
return currentTime;
|
return currentTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Stopwatch = ({ offsetTimestamp }: { offsetTimestamp: Date }) => {
|
interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
|
||||||
|
offsetTimestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stopwatch = ({ offsetTimestamp, ...props }: StopwatchProps) => {
|
||||||
const { totalSeconds } = useStopwatch({
|
const { totalSeconds } = useStopwatch({
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
offsetTimestamp: offsetTimestamp,
|
offsetTimestamp: offsetTimestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <FormatMillisecond time={totalSeconds * 1000} />;
|
return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Stopwatch, setStopWatchOffset };
|
export { Stopwatch, setStopWatchOffset };
|
||||||
|
120
packages/frontend/src/components/VerticalStepper.tsx
Normal file
120
packages/frontend/src/components/VerticalStepper.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import * as CSS from 'csstype';
|
||||||
|
|
||||||
|
//
|
||||||
|
// Nav
|
||||||
|
//
|
||||||
|
export interface IStepDescription {
|
||||||
|
stepContent: () => JSX.Element;
|
||||||
|
stepStateColor?: string;
|
||||||
|
stepStatusCircleSize?: number;
|
||||||
|
onClickHandler?: () => void | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IStepperNavProps {
|
||||||
|
steps: IStepDescription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepperNav = (props: IStepperNavProps): JSX.Element => {
|
||||||
|
return (
|
||||||
|
<nav>
|
||||||
|
{props.steps.map(
|
||||||
|
(
|
||||||
|
{ stepContent, stepStateColor, onClickHandler, stepStatusCircleSize },
|
||||||
|
index,
|
||||||
|
) => (
|
||||||
|
<div key={index}>
|
||||||
|
<Step
|
||||||
|
stepContent={stepContent}
|
||||||
|
statusColor={stepStateColor}
|
||||||
|
onClickHandler={onClickHandler}
|
||||||
|
statusCircleSize={stepStatusCircleSize}
|
||||||
|
/>
|
||||||
|
{index !== props.steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
paddingLeft: `${(stepStatusCircleSize ?? 16) / 2 + 1}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Separator
|
||||||
|
//
|
||||||
|
const separatorStyles = {
|
||||||
|
height: '5vh',
|
||||||
|
width: 2,
|
||||||
|
border: '1px solid #E1E1E1',
|
||||||
|
background: '#E1E1E1',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ISeparator {
|
||||||
|
height?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Separator = ({ height }: ISeparator): JSX.Element => {
|
||||||
|
return <div style={{ ...separatorStyles, height: height ?? '5vh' }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
// Step
|
||||||
|
//
|
||||||
|
export interface IStep {
|
||||||
|
stepContent: () => JSX.Element;
|
||||||
|
statusColor?: string;
|
||||||
|
statusCircleSize?: number;
|
||||||
|
onClickHandler?: (
|
||||||
|
event?: React.MouseEvent<HTMLDivElement>,
|
||||||
|
) => void | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonContainerStyles: CSS.Properties = {
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '2px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Step = ({
|
||||||
|
stepContent,
|
||||||
|
statusColor,
|
||||||
|
statusCircleSize,
|
||||||
|
onClickHandler,
|
||||||
|
}: IStep): JSX.Element => {
|
||||||
|
const circleStyles = {
|
||||||
|
borderRadius: statusCircleSize ?? 16,
|
||||||
|
width: statusCircleSize ?? 16,
|
||||||
|
height: statusCircleSize ?? 16,
|
||||||
|
border: '2px solid #E1E1E1',
|
||||||
|
background: statusColor ?? 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyDownHandler = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.keyCode === 13 || event.keyCode === 32) {
|
||||||
|
onClickHandler?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClickHandler}
|
||||||
|
onKeyDown={keyDownHandler}
|
||||||
|
role="button"
|
||||||
|
style={{ ...buttonContainerStyles }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={circleStyles} />
|
||||||
|
</div>
|
||||||
|
<div style={{ paddingBottom: 2 }}>{stepContent()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface CancelDeploymentDialogProps extends ConfirmDialogProps {}
|
||||||
|
|
||||||
|
export const CancelDeploymentDialog = ({
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: CancelDeploymentDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Cancel deployment?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, cancel deployment"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-high-em tracking-[-0.006em]">
|
||||||
|
This will halt the deployment and you'll have to start the process
|
||||||
|
from scratch.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,90 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
import { Deployment, Domain } from 'gql-client';
|
||||||
|
|
||||||
|
import DeploymentDialogBodyCard from 'components/projects/project/deployments/DeploymentDialogBodyCard';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import {
|
||||||
|
ChevronDoubleDownIcon,
|
||||||
|
LinkChainIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { TagProps } from 'components/shared/Tag';
|
||||||
|
|
||||||
|
interface ChangeStateToProductionDialogProps extends ConfirmDialogProps {
|
||||||
|
deployment: Deployment;
|
||||||
|
newDeployment?: Deployment;
|
||||||
|
domains: Domain[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeStateToProductionDialog = ({
|
||||||
|
deployment,
|
||||||
|
newDeployment,
|
||||||
|
domains,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: ChangeStateToProductionDialogProps) => {
|
||||||
|
const currentChip = {
|
||||||
|
value: 'Live Deployment',
|
||||||
|
type: 'positive' as TagProps['type'],
|
||||||
|
};
|
||||||
|
const newChip = {
|
||||||
|
value: 'New Deployment',
|
||||||
|
type: 'attention' as TagProps['type'],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-7">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-elements-high-em tracking-[-0.006em]">
|
||||||
|
Upon confirmation, this deployment will be changed to production.
|
||||||
|
</p>
|
||||||
|
<DeploymentDialogBodyCard
|
||||||
|
deployment={deployment}
|
||||||
|
chip={newDeployment ? currentChip : undefined}
|
||||||
|
/>
|
||||||
|
{newDeployment && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between w-full text-elements-info">
|
||||||
|
{Array.from({ length: 7 }).map((_, index) => (
|
||||||
|
<ChevronDoubleDownIcon key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DeploymentDialogBodyCard
|
||||||
|
deployment={newDeployment}
|
||||||
|
chip={newChip}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start gap-3">
|
||||||
|
<p className="text-sm text-elements-high-em tracking-[-0.006em]">
|
||||||
|
The new deployment will be associated with these domains:
|
||||||
|
</p>
|
||||||
|
{domains.length > 0 &&
|
||||||
|
domains.map((value) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
href={value.name}
|
||||||
|
leftIcon={<LinkChainIcon size={18} />}
|
||||||
|
variant="link"
|
||||||
|
key={value.id}
|
||||||
|
>
|
||||||
|
{value.name}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,41 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface DeleteDomainDialogProps extends ConfirmDialogProps {
|
||||||
|
projectName: string;
|
||||||
|
domainName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteDomainDialog = ({
|
||||||
|
projectName,
|
||||||
|
domainName,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: DeleteDomainDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Delete domain?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, delete domain"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-high-em">
|
||||||
|
Once deleted, the project{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{projectName}
|
||||||
|
</span>{' '}
|
||||||
|
will not be accessible from the domain{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{domainName}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,35 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface DeleteVariableDialogProps extends ConfirmDialogProps {
|
||||||
|
variableKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteVariableDialog = ({
|
||||||
|
variableKey,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: DeleteVariableDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Delete variable"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, confirm delete"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-mid-em">
|
||||||
|
Are you sure you want to delete the variable{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{variableKey}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,35 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface DeleteWebhookDialogProps extends ConfirmDialogProps {
|
||||||
|
webhookUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteWebhookDialog = ({
|
||||||
|
webhookUrl,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: DeleteWebhookDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Delete webhook?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, confirm delete"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-mid-em">
|
||||||
|
Are you sure you want to delete{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-high-em px-0.5">
|
||||||
|
{webhookUrl}
|
||||||
|
</span>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface DisconnectRepositoryDialogProps extends ConfirmDialogProps {}
|
||||||
|
|
||||||
|
export const DisconnectRepositoryDialog = ({
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: DisconnectRepositoryDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Disconnect repository?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, confirm disconnect"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-high-em">
|
||||||
|
Any data tied to your Git project may become misconfigured. Are you sure
|
||||||
|
you want to continue?
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,38 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
import { formatAddress } from 'utils/format';
|
||||||
|
|
||||||
|
interface RemoveMemberDialogProps extends ConfirmDialogProps {
|
||||||
|
memberName: string;
|
||||||
|
ethAddress: string;
|
||||||
|
emailDomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RemoveMemberDialog = ({
|
||||||
|
memberName,
|
||||||
|
ethAddress,
|
||||||
|
emailDomain,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: RemoveMemberDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Remove member?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, remove member"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
confirmButtonProps={{ variant: 'danger' }}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-high-em">
|
||||||
|
Once removed, {formatAddress(memberName)} ({formatAddress(ethAddress)}@
|
||||||
|
{emailDomain}) will not be able to access this project.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,46 @@
|
|||||||
|
import ConfirmDialog, {
|
||||||
|
ConfirmDialogProps,
|
||||||
|
} from 'components/shared/ConfirmDialog';
|
||||||
|
|
||||||
|
interface TransferProjectDialogProps extends ConfirmDialogProps {
|
||||||
|
projectName: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TransferProjectDialog = ({
|
||||||
|
projectName,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
open,
|
||||||
|
handleCancel,
|
||||||
|
handleConfirm,
|
||||||
|
...props
|
||||||
|
}: TransferProjectDialogProps) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
{...props}
|
||||||
|
dialogTitle="Transfer project?"
|
||||||
|
handleCancel={handleCancel}
|
||||||
|
open={open}
|
||||||
|
confirmButtonTitle="Yes, confirm transfer"
|
||||||
|
handleConfirm={handleConfirm}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-elements-high-em">
|
||||||
|
Upon confirmation, your project{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{projectName}
|
||||||
|
</span>{' '}
|
||||||
|
will be transferred from{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{from}
|
||||||
|
</span>{' '}
|
||||||
|
to{' '}
|
||||||
|
<span className="text-sm font-mono text-elements-on-secondary bg-controls-secondary rounded px-0.5">
|
||||||
|
{to}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</ConfirmDialog>
|
||||||
|
);
|
||||||
|
};
|
@ -9,16 +9,18 @@ export const projectCardTheme = tv({
|
|||||||
'flex',
|
'flex',
|
||||||
'flex-col',
|
'flex-col',
|
||||||
'group',
|
'group',
|
||||||
|
'cursor-pointer',
|
||||||
],
|
],
|
||||||
upperContent: ['px-4', 'py-4', 'flex', 'items-start', 'gap-3', 'relative'],
|
upperContent: ['px-4', 'py-4', 'flex', 'items-start', 'gap-3', 'relative'],
|
||||||
content: ['flex', 'flex-col', 'gap-1', 'flex-1'],
|
content: ['flex', 'flex-col', 'gap-1', 'flex-1', 'overflow-hidden'],
|
||||||
title: [
|
title: [
|
||||||
'text-sm',
|
'text-sm',
|
||||||
'font-medium',
|
'font-medium',
|
||||||
'text-elements-high-em',
|
'text-elements-high-em',
|
||||||
'tracking-[-0.006em]',
|
'tracking-[-0.006em]',
|
||||||
|
'truncate',
|
||||||
],
|
],
|
||||||
description: ['text-xs', 'text-elements-low-em'],
|
description: ['text-xs', 'text-elements-low-em', 'truncate'],
|
||||||
icons: ['flex', 'items-center', 'gap-1'],
|
icons: ['flex', 'items-center', 'gap-1'],
|
||||||
lowerContent: [
|
lowerContent: [
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
|
@ -1,29 +1,26 @@
|
|||||||
import React, {
|
|
||||||
ComponentPropsWithoutRef,
|
|
||||||
MouseEvent,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import { ProjectCardTheme, projectCardTheme } from './ProjectCard.theme';
|
|
||||||
import { Project } from 'gql-client';
|
|
||||||
import { Button } from 'components/shared/Button';
|
|
||||||
import { WavyBorder } from 'components/shared/WavyBorder';
|
|
||||||
import {
|
|
||||||
BranchIcon,
|
|
||||||
ClockIcon,
|
|
||||||
GitHubLogo,
|
|
||||||
HorizontalDotIcon,
|
|
||||||
WarningDiamondIcon,
|
|
||||||
} from 'components/shared/CustomIcon';
|
|
||||||
import { relativeTimeMs } from 'utils/time';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Avatar } from 'components/shared/Avatar';
|
|
||||||
import { getInitials } from 'utils/geInitials';
|
|
||||||
import {
|
import {
|
||||||
Menu,
|
Menu,
|
||||||
MenuHandler,
|
MenuHandler,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
} from '@material-tailwind/react';
|
} from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
import { ComponentPropsWithoutRef, MouseEvent, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Project } from 'gql-client';
|
||||||
|
import { Avatar } from 'components/shared/Avatar';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import {
|
||||||
|
BranchIcon,
|
||||||
|
ClockIcon,
|
||||||
|
GithubLogoIcon,
|
||||||
|
HorizontalDotIcon,
|
||||||
|
WarningDiamondIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { Tooltip } from 'components/shared/Tooltip';
|
||||||
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
|
import { relativeTimeMs } from 'utils/time';
|
||||||
|
import { getInitials } from 'utils/geInitials';
|
||||||
|
import { ProjectCardTheme, projectCardTheme } from './ProjectCard.theme';
|
||||||
|
|
||||||
export interface ProjectCardProps
|
export interface ProjectCardProps
|
||||||
extends ComponentPropsWithoutRef<'div'>,
|
extends ComponentPropsWithoutRef<'div'>,
|
||||||
@ -72,7 +69,9 @@ export const ProjectCard = ({
|
|||||||
/>
|
/>
|
||||||
{/* Title and website */}
|
{/* Title and website */}
|
||||||
<div className={theme.content()}>
|
<div className={theme.content()}>
|
||||||
|
<Tooltip content={project.name}>
|
||||||
<p className={theme.title()}>{project.name}</p>
|
<p className={theme.title()}>{project.name}</p>
|
||||||
|
</Tooltip>
|
||||||
<p className={theme.description()}>
|
<p className={theme.description()}>
|
||||||
{project.deployments[0]?.domain?.name ?? 'No domain'}
|
{project.deployments[0]?.domain?.name ?? 'No domain'}
|
||||||
</p>
|
</p>
|
||||||
@ -92,11 +91,9 @@ export const ProjectCard = ({
|
|||||||
<HorizontalDotIcon />
|
<HorizontalDotIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</MenuHandler>
|
</MenuHandler>
|
||||||
<MenuList placeholder={''}>
|
<MenuList>
|
||||||
<MenuItem placeholder={''}>Project settings</MenuItem>
|
<MenuItem>Project settings</MenuItem>
|
||||||
<MenuItem className="text-red-500" placeholder={''}>
|
<MenuItem className="text-red-500">Delete project</MenuItem>
|
||||||
Delete project
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
@ -121,7 +118,7 @@ export const ProjectCard = ({
|
|||||||
<div className={theme.deploymentText()}>
|
<div className={theme.deploymentText()}>
|
||||||
{hasDeployment ? (
|
{hasDeployment ? (
|
||||||
<>
|
<>
|
||||||
<GitHubLogo />
|
<GithubLogoIcon />
|
||||||
<span>{relativeTimeMs(project.deployments[0].createdAt)} on</span>
|
<span>{relativeTimeMs(project.deployments[0].createdAt)} on</span>
|
||||||
<BranchIcon />
|
<BranchIcon />
|
||||||
<span>{project.deployments[0].branch}</span>
|
<span>{project.deployments[0].branch}</span>
|
||||||
|
@ -1,127 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useCombobox } from 'downshift';
|
|
||||||
import { Project } from 'gql-client';
|
|
||||||
import { useDebounce } from 'usehooks-ts';
|
|
||||||
|
|
||||||
import {
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemPrefix,
|
|
||||||
Card,
|
|
||||||
Typography,
|
|
||||||
Avatar,
|
|
||||||
} from '@material-tailwind/react';
|
|
||||||
|
|
||||||
import SearchBar from '../SearchBar';
|
|
||||||
import { useGQLClient } from '../../context/GQLClientContext';
|
|
||||||
|
|
||||||
interface ProjectsSearchProps {
|
|
||||||
onChange?: (data: Project) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectSearchBar = ({ onChange }: ProjectsSearchProps) => {
|
|
||||||
const [items, setItems] = useState<Project[]>([]);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<Project | null>(null);
|
|
||||||
const client = useGQLClient();
|
|
||||||
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
getMenuProps,
|
|
||||||
getInputProps,
|
|
||||||
getItemProps,
|
|
||||||
highlightedIndex,
|
|
||||||
inputValue,
|
|
||||||
} = useCombobox({
|
|
||||||
items,
|
|
||||||
itemToString(item) {
|
|
||||||
return item ? item.name : '';
|
|
||||||
},
|
|
||||||
selectedItem,
|
|
||||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
|
||||||
if (newSelectedItem) {
|
|
||||||
setSelectedItem(newSelectedItem);
|
|
||||||
|
|
||||||
if (onChange) {
|
|
||||||
onChange(newSelectedItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const debouncedInputValue = useDebounce<string>(inputValue, 500);
|
|
||||||
|
|
||||||
const fetchProjects = useCallback(
|
|
||||||
async (inputValue: string) => {
|
|
||||||
const { searchProjects } = await client.searchProjects(inputValue);
|
|
||||||
setItems(searchProjects);
|
|
||||||
},
|
|
||||||
[client],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debouncedInputValue) {
|
|
||||||
fetchProjects(debouncedInputValue);
|
|
||||||
}
|
|
||||||
}, [fetchProjects, debouncedInputValue]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<SearchBar {...getInputProps()} />
|
|
||||||
<Card
|
|
||||||
className={`absolute w-1/2 max-h-52 -mt-1 overflow-y-auto ${
|
|
||||||
(!inputValue || !isOpen) && 'hidden'
|
|
||||||
}`}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
<List {...getMenuProps()}>
|
|
||||||
{items.length ? (
|
|
||||||
<>
|
|
||||||
<div className="p-3">
|
|
||||||
<Typography variant="small" color="gray" placeholder={''}>
|
|
||||||
Suggestions
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<ListItem
|
|
||||||
selected={highlightedIndex === index || selectedItem === item}
|
|
||||||
key={item.id}
|
|
||||||
placeholder={''}
|
|
||||||
{...getItemProps({ item, index })}
|
|
||||||
>
|
|
||||||
<ListItemPrefix placeholder={''}>
|
|
||||||
<Avatar
|
|
||||||
src={item.icon || '/gray.png'}
|
|
||||||
variant="rounded"
|
|
||||||
placeholder={''}
|
|
||||||
/>
|
|
||||||
</ListItemPrefix>
|
|
||||||
<div>
|
|
||||||
<Typography variant="h6" color="blue-gray" placeholder={''}>
|
|
||||||
{item.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
color="gray"
|
|
||||||
className="font-normal"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
{item.organization.name}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</ListItem>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-3">
|
|
||||||
<Typography placeholder={''}>
|
|
||||||
^ No projects matching this name
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectSearchBar;
|
|
@ -0,0 +1,93 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useCombobox } from 'downshift';
|
||||||
|
import { Project } from 'gql-client';
|
||||||
|
import { useDebounce } from 'usehooks-ts';
|
||||||
|
|
||||||
|
import SearchBar from 'components/SearchBar';
|
||||||
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
import { ProjectSearchBarItem } from './ProjectSearchBarItem';
|
||||||
|
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
|
||||||
|
|
||||||
|
interface ProjectSearchBarProps {
|
||||||
|
onChange?: (data: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
|
||||||
|
const [items, setItems] = useState<Project[]>([]);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<Project | null>(null);
|
||||||
|
const client = useGQLClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
getMenuProps,
|
||||||
|
getInputProps,
|
||||||
|
getItemProps,
|
||||||
|
highlightedIndex,
|
||||||
|
inputValue,
|
||||||
|
} = useCombobox({
|
||||||
|
items,
|
||||||
|
itemToString(item) {
|
||||||
|
return item ? item.name : '';
|
||||||
|
},
|
||||||
|
selectedItem,
|
||||||
|
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||||
|
if (newSelectedItem) {
|
||||||
|
setSelectedItem(newSelectedItem);
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
onChange(newSelectedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedInputValue = useDebounce<string>(inputValue, 300);
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(
|
||||||
|
async (inputValue: string) => {
|
||||||
|
const { searchProjects } = await client.searchProjects(inputValue);
|
||||||
|
setItems(searchProjects);
|
||||||
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedInputValue) {
|
||||||
|
fetchProjects(debouncedInputValue);
|
||||||
|
}
|
||||||
|
}, [fetchProjects, debouncedInputValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full lg:w-fit">
|
||||||
|
<SearchBar {...getInputProps()} />
|
||||||
|
<div
|
||||||
|
{...getMenuProps()}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col shadow-dropdown rounded-xl bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50',
|
||||||
|
{ hidden: !inputValue || !isOpen },
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.length ? (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<p className="text-elements-mid-em text-xs font-medium">
|
||||||
|
Suggestions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<ProjectSearchBarItem
|
||||||
|
{...getItemProps({ item, index })}
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
active={highlightedIndex === index || selectedItem === item}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<ProjectSearchBarEmpty />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { CrossIcon, SearchIcon } from 'components/shared/CustomIcon';
|
||||||
|
import { Input } from 'components/shared/Input';
|
||||||
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
|
import { Project } from 'gql-client';
|
||||||
|
import { useDebounce } from 'usehooks-ts';
|
||||||
|
import { ProjectSearchBarItem } from './ProjectSearchBarItem';
|
||||||
|
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCombobox } from 'downshift';
|
||||||
|
|
||||||
|
interface ProjectSearchBarDialogProps extends Dialog.DialogProps {
|
||||||
|
open?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
onClickItem?: (data: Project) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectSearchBarDialog = ({
|
||||||
|
onClose,
|
||||||
|
onClickItem,
|
||||||
|
...props
|
||||||
|
}: ProjectSearchBarDialogProps) => {
|
||||||
|
const [items, setItems] = useState<Project[]>([]);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<Project | null>(null);
|
||||||
|
const client = useGQLClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { getInputProps, getItemProps, inputValue, setInputValue } =
|
||||||
|
useCombobox({
|
||||||
|
items,
|
||||||
|
itemToString(item) {
|
||||||
|
return item ? item.name : '';
|
||||||
|
},
|
||||||
|
selectedItem,
|
||||||
|
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||||
|
if (newSelectedItem) {
|
||||||
|
setSelectedItem(newSelectedItem);
|
||||||
|
onClickItem?.(newSelectedItem);
|
||||||
|
navigate(
|
||||||
|
`/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const debouncedInputValue = useDebounce<string>(inputValue, 300);
|
||||||
|
|
||||||
|
const fetchProjects = useCallback(
|
||||||
|
async (inputValue: string) => {
|
||||||
|
const { searchProjects } = await client.searchProjects(inputValue);
|
||||||
|
setItems(searchProjects);
|
||||||
|
},
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedInputValue) {
|
||||||
|
fetchProjects(debouncedInputValue);
|
||||||
|
}
|
||||||
|
}, [fetchProjects, debouncedInputValue]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setInputValue('');
|
||||||
|
setItems([]);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root {...props}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="bg-base-bg fixed inset-0 md:hidden overflow-y-auto" />
|
||||||
|
<Dialog.Content>
|
||||||
|
<div className="h-full flex flex-col fixed top-0 inset-0">
|
||||||
|
<div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]">
|
||||||
|
<Input
|
||||||
|
{...getInputProps()}
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
placeholder="Search"
|
||||||
|
appearance="borderless"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button iconOnly variant="ghost" onClick={handleClose}>
|
||||||
|
<CrossIcon size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-col gap-1 px-2 py-2">
|
||||||
|
{items.length > 0
|
||||||
|
? items.map((item, index) => (
|
||||||
|
<>
|
||||||
|
<div className="px-2 py-2">
|
||||||
|
<p className="text-elements-mid-em text-xs font-medium">
|
||||||
|
Suggestions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProjectSearchBarItem
|
||||||
|
{...getItemProps({ item, index })}
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
: inputValue && <ProjectSearchBarEmpty />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,24 @@
|
|||||||
|
import { InfoRoundFilledIcon } from 'components/shared/CustomIcon';
|
||||||
|
import { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
|
||||||
|
interface ProjectSearchBarEmptyProps extends ComponentPropsWithoutRef<'div'> {}
|
||||||
|
|
||||||
|
export const ProjectSearchBarEmpty = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ProjectSearchBarEmptyProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn('flex items-center px-2 py-2 gap-3', className)}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-orange-50 text-elements-warning">
|
||||||
|
<InfoRoundFilledIcon size={16} />
|
||||||
|
</div>
|
||||||
|
<p className="text-elements-low-em text-sm tracking-[-0.006em]">
|
||||||
|
No projects matching this name
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,59 @@
|
|||||||
|
import { Avatar } from 'components/shared/Avatar';
|
||||||
|
import { Overwrite, UseComboboxGetItemPropsReturnValue } from 'downshift';
|
||||||
|
import { Project } from 'gql-client';
|
||||||
|
import { ComponentPropsWithoutRef, forwardRef } from 'react';
|
||||||
|
import { OmitCommon } from 'types/common';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
import { getInitials } from 'utils/geInitials';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a type that merges ComponentPropsWithoutRef<'li'> with certain exclusions.
|
||||||
|
* @type {MergedComponentPropsWithoutRef}
|
||||||
|
*/
|
||||||
|
type MergedComponentPropsWithoutRef = OmitCommon<
|
||||||
|
ComponentPropsWithoutRef<'button'>,
|
||||||
|
Omit<
|
||||||
|
Overwrite<UseComboboxGetItemPropsReturnValue, Project[]>,
|
||||||
|
'index' | 'item'
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface ProjectSearchBarItemProps extends MergedComponentPropsWithoutRef {
|
||||||
|
item: Project;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProjectSearchBarItem = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ProjectSearchBarItemProps
|
||||||
|
>(({ item, active, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
key={item.id}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-2 flex items-center gap-3 rounded-lg text-left hover:bg-base-bg-emphasized',
|
||||||
|
{
|
||||||
|
'bg-base-bg-emphasized': active,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
imageSrc={item.icon}
|
||||||
|
initials={getInitials(item.name)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
<p className="text-sm tracking-[-0.006em] text-elements-high-em">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-elements-low-em">{item.organization.name}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProjectSearchBarItem.displayName = 'ProjectSearchBarItem';
|
||||||
|
|
||||||
|
export { ProjectSearchBarItem };
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './ProjectSearchBar';
|
||||||
|
export * from './ProjectSearchBarDialog';
|
@ -2,21 +2,22 @@ import React from 'react';
|
|||||||
import OauthPopup from 'react-oauth-popup';
|
import OauthPopup from 'react-oauth-popup';
|
||||||
|
|
||||||
import { useGQLClient } from '../../../context/GQLClientContext';
|
import { useGQLClient } from '../../../context/GQLClientContext';
|
||||||
import { Button } from 'components/shared/Button';
|
import { Button } from '../../shared/Button';
|
||||||
import {
|
import {
|
||||||
GitIcon,
|
GitIcon,
|
||||||
EllipsesIcon,
|
EllipsesIcon,
|
||||||
SnowballIcon,
|
SnowballIcon,
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
GitTeaIcon,
|
GitTeaIcon,
|
||||||
} from 'components/shared/CustomIcon';
|
} from '../../shared/CustomIcon';
|
||||||
import { useToast } from 'components/shared/Toast';
|
import { useToast } from '../../shared/Toast';
|
||||||
import { IconWithFrame } from 'components/shared/IconWithFrame';
|
import { IconWithFrame } from '../../shared/IconWithFrame';
|
||||||
import { Heading } from 'components/shared/Heading';
|
import { Heading } from '../../shared/Heading';
|
||||||
|
import { MockConnectGitCard } from './MockConnectGitCard';
|
||||||
|
|
||||||
const SCOPES = 'repo user';
|
const SCOPES = 'repo user';
|
||||||
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
|
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
|
||||||
process.env.REACT_APP_GITHUB_CLIENT_ID
|
import.meta.env.VITE_GITHUB_CLIENT_ID
|
||||||
}&scope=${encodeURIComponent(SCOPES)}`;
|
}&scope=${encodeURIComponent(SCOPES)}`;
|
||||||
|
|
||||||
interface ConnectAccountInterface {
|
interface ConnectAccountInterface {
|
||||||
@ -93,6 +94,7 @@ const ConnectAccount: React.FC<ConnectAccountInterface> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Add ConnectAccountTabPanel */}
|
{/* TODO: Add ConnectAccountTabPanel */}
|
||||||
|
<MockConnectGitCard />
|
||||||
{/* <div className="rounded-l shadow p-2 flex-col justify-start items-start gap-2 inline-flex">
|
{/* <div className="rounded-l shadow p-2 flex-col justify-start items-start gap-2 inline-flex">
|
||||||
<ConnectAccountTabPanel />
|
<ConnectAccountTabPanel />
|
||||||
</div> */}
|
</div> */}
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { Button, Typography } from '@material-tailwind/react';
|
|
||||||
|
|
||||||
import { DeployStep, DeployStatus } from './DeployStep';
|
import { DeployStep, DeployStatus } from './DeployStep';
|
||||||
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
|
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
|
||||||
import ConfirmDialog from 'components/shared/ConfirmDialog';
|
import { Heading } from '../../shared/Heading';
|
||||||
|
import { Button } from '../../shared/Button';
|
||||||
|
import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon';
|
||||||
|
import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog';
|
||||||
|
|
||||||
const TIMEOUT_DURATION = 5000;
|
const TIMEOUT_DURATION = 5000;
|
||||||
const Deploy = () => {
|
const Deploy = () => {
|
||||||
@ -31,41 +32,35 @@ const Deploy = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-7">
|
||||||
<div className="flex justify-between mb-6">
|
<div className="flex justify-between">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<h4>Deployment started ...</h4>
|
<Heading as="h4" className="md:text-lg font-medium">
|
||||||
<div className="flex">
|
Deployment started ...
|
||||||
^
|
</Heading>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ClockOutlineIcon size={16} className="text-elements-mid-em" />
|
||||||
<Stopwatch
|
<Stopwatch
|
||||||
offsetTimestamp={setStopWatchOffset(Date.now().toString())}
|
offsetTimestamp={setStopWatchOffset(Date.now().toString())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
variant="outlined"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
placeholder={''}
|
variant="tertiary"
|
||||||
|
leftIcon={<WarningIcon size={16} />}
|
||||||
>
|
>
|
||||||
^ Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<CancelDeploymentDialog
|
||||||
<ConfirmDialog
|
handleCancel={handleOpen}
|
||||||
dialogTitle="Cancel deployment?"
|
|
||||||
handleOpen={handleOpen}
|
|
||||||
open={open}
|
open={open}
|
||||||
confirmButtonTitle="Yes, Cancel deployment"
|
|
||||||
handleConfirm={handleCancel}
|
handleConfirm={handleCancel}
|
||||||
color="red"
|
/>
|
||||||
>
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
This will halt the deployment and you will have to start the process
|
|
||||||
from scratch.
|
|
||||||
</Typography>
|
|
||||||
</ConfirmDialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
<DeployStep
|
<DeployStep
|
||||||
title="Building"
|
title="Building"
|
||||||
status={DeployStatus.COMPLETE}
|
status={DeployStatus.COMPLETE}
|
||||||
@ -89,6 +84,7 @@ const Deploy = () => {
|
|||||||
step="4"
|
step="4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,11 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
|
|
||||||
import { Collapse, Button, Typography } from '@material-tailwind/react';
|
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
|
import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
|
||||||
import FormatMillisecond from '../../FormatMilliSecond';
|
import FormatMillisecond from '../../FormatMilliSecond';
|
||||||
import processLogs from '../../../assets/process-logs.json';
|
import processLogs from '../../../assets/process-logs.json';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
import {
|
||||||
|
CheckRoundFilledIcon,
|
||||||
|
ClockOutlineIcon,
|
||||||
|
CopyIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
MinusCircleIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { useToast } from 'components/shared/Toast';
|
||||||
|
import { useIntersectionObserver } from 'usehooks-ts';
|
||||||
|
|
||||||
enum DeployStatus {
|
enum DeployStatus {
|
||||||
PROCESSING = 'progress',
|
PROCESSING = 'progress',
|
||||||
@ -28,61 +39,115 @@ const DeployStep = ({
|
|||||||
startTime,
|
startTime,
|
||||||
processTime,
|
processTime,
|
||||||
}: DeployStepsProps) => {
|
}: DeployStepsProps) => {
|
||||||
const [collapse, setCollapse] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { toast, dismiss } = useToast();
|
||||||
|
const { isIntersecting: hideGradientOverlay, ref } = useIntersectionObserver({
|
||||||
|
threshold: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const disableCollapse = status !== DeployStatus.COMPLETE;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b-2">
|
<div className="border-b border-border-separator">
|
||||||
<div className="flex justify-between p-2 gap-2">
|
{/* Collapisble trigger */}
|
||||||
{status === DeployStatus.NOT_STARTED && <div>{step}</div>}
|
|
||||||
{status === DeployStatus.PROCESSING && <div>O</div>}
|
|
||||||
{status === DeployStatus.COMPLETE && (
|
|
||||||
<div>
|
|
||||||
<button
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex justify-between w-full py-5 gap-2',
|
||||||
|
disableCollapse && 'cursor-auto',
|
||||||
|
)}
|
||||||
|
tabIndex={disableCollapse ? -1 : undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCollapse(!collapse);
|
if (!disableCollapse) {
|
||||||
|
setIsOpen((val) => !val);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{collapse ? '-' : '+'}
|
<div className={cn('grow flex items-center gap-3')}>
|
||||||
</button>
|
{/* Icon */}
|
||||||
|
<div className="w-6 h-6 grid place-content-center">
|
||||||
|
{status === DeployStatus.NOT_STARTED && (
|
||||||
|
<div className="grid place-content-center w-6 h-6 rounded-[48px] bg-base-bg-emphasized font-mono text-xs">
|
||||||
|
{step}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grow">{title}</div>
|
|
||||||
{status === DeployStatus.PROCESSING && (
|
{status === DeployStatus.PROCESSING && (
|
||||||
<>
|
<LoaderIcon className="animate-spin text-elements-link" />
|
||||||
^<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{status === DeployStatus.COMPLETE && (
|
{status === DeployStatus.COMPLETE && (
|
||||||
<>
|
<div className="text-controls-primary">
|
||||||
^<FormatMillisecond time={Number(processTime)} />{' '}
|
{!isOpen && <PlusIcon size={24} />}
|
||||||
</>
|
{isOpen && <MinusCircleIcon size={24} />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Collapse open={collapse}>
|
|
||||||
<div className="p-2 text-sm text-gray-500 h-36 overflow-y-scroll">
|
{/* Title */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-left text-sm md:text-base',
|
||||||
|
status === DeployStatus.PROCESSING && 'text-elements-link',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer */}
|
||||||
|
{status === DeployStatus.PROCESSING && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ClockOutlineIcon size={16} className="text-elements-low-em" />
|
||||||
|
<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === DeployStatus.COMPLETE && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-4.5 h-4.5 grid place-content-center">
|
||||||
|
<CheckRoundFilledIcon
|
||||||
|
className="text-elements-success"
|
||||||
|
size={15}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormatMillisecond time={Number(processTime)} />{' '}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Collapsible */}
|
||||||
|
<Collapse open={isOpen}>
|
||||||
|
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
|
||||||
|
{/* Logs */}
|
||||||
{processLogs.map((log, key) => {
|
{processLogs.map((log, key) => {
|
||||||
return (
|
return (
|
||||||
<Typography
|
<p className="font-mono" key={key}>
|
||||||
variant="small"
|
|
||||||
color="gray"
|
|
||||||
key={key}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
{log}
|
{log}
|
||||||
</Typography>
|
</p>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<div className="sticky bottom-0 left-1/2 flex justify-center">
|
|
||||||
|
{/* End of logs ref used for hiding gradient overlay */}
|
||||||
|
<div ref={ref} />
|
||||||
|
|
||||||
|
{/* Overflow gradient overlay */}
|
||||||
|
{!hideGradientOverlay && (
|
||||||
|
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Copy log button */}
|
||||||
|
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="xs"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(processLogs.join('\n'));
|
navigator.clipboard.writeText(processLogs.join('\n'));
|
||||||
toast.success('Logs copied');
|
toast({
|
||||||
|
title: 'Logs copied',
|
||||||
|
variant: 'success',
|
||||||
|
id: 'logs',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
color="blue"
|
leftIcon={<CopyIcon size={16} />}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
^ Copy log
|
Copy log
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,163 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { SegmentedControls } from 'components/shared/SegmentedControls';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
GithubIcon,
|
||||||
|
LockIcon,
|
||||||
|
TemplateIcon,
|
||||||
|
TemplateIconType,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { relativeTimeISO } from 'utils/time';
|
||||||
|
import { useMediaQuery } from 'usehooks-ts';
|
||||||
|
|
||||||
|
export const MockConnectGitCard = () => {
|
||||||
|
const [segmentedControlsValue, setSegmentedControlsValue] =
|
||||||
|
useState<string>('import');
|
||||||
|
|
||||||
|
const isDesktopView = useMediaQuery('(min-width: 960px)'); // lg:
|
||||||
|
const segmentedControlsProps = isDesktopView ? {} : { size: 'sm' as const };
|
||||||
|
|
||||||
|
const fiveMinutesAgo = new Date(Date.now() - 1000 * 300).toISOString();
|
||||||
|
|
||||||
|
const SEGMENTED_CONTROLS_OPTIONS = [
|
||||||
|
{
|
||||||
|
label: 'Import a repository',
|
||||||
|
value: 'import',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Start with a template',
|
||||||
|
value: 'template',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const IMPORT_CONTENT = [
|
||||||
|
{
|
||||||
|
full_name: 'snowball/igloo',
|
||||||
|
updated_at: fiveMinutesAgo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: 'snowball/android-sdk',
|
||||||
|
updated_at: fiveMinutesAgo,
|
||||||
|
visibility: 'private',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
full_name: 'snowball/landing-page',
|
||||||
|
updated_at: fiveMinutesAgo,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEMPLATE_CONTENT = [
|
||||||
|
{
|
||||||
|
name: 'Web app',
|
||||||
|
icon: 'web',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Progressive Web App (PWA)',
|
||||||
|
icon: 'pwa',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'React Native',
|
||||||
|
icon: 'react-native',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Kotlin',
|
||||||
|
icon: 'kotlin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Swift',
|
||||||
|
icon: 'swift',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderContent = useMemo(() => {
|
||||||
|
if (segmentedControlsValue === 'import') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 relative z-0">
|
||||||
|
{IMPORT_CONTENT.map((repo, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<MockProjectCard {...repo} />
|
||||||
|
{index !== IMPORT_CONTENT.length - 1 && (
|
||||||
|
<div className="border-b border-base-border" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 relative z-0">
|
||||||
|
{TEMPLATE_CONTENT.map((template, index) => (
|
||||||
|
<MockTemplateCard key={index} {...template} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [segmentedControlsValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-base-bg shadow-card rounded-2xl px-2 py-2 w-full max-w-[560px] flex flex-col gap-2">
|
||||||
|
{/* Content */}
|
||||||
|
<SegmentedControls
|
||||||
|
value={segmentedControlsValue}
|
||||||
|
onChange={setSegmentedControlsValue}
|
||||||
|
options={SEGMENTED_CONTROLS_OPTIONS}
|
||||||
|
type="full-width"
|
||||||
|
{...segmentedControlsProps}
|
||||||
|
/>
|
||||||
|
{renderContent}
|
||||||
|
|
||||||
|
{/* Shade */}
|
||||||
|
<div className="pointer-events-none z-99 absolute inset-0 rounded-2xl bg-gradient-to-t from-white to-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MockProjectCard = ({
|
||||||
|
full_name,
|
||||||
|
updated_at,
|
||||||
|
visibility,
|
||||||
|
}: {
|
||||||
|
full_name: string;
|
||||||
|
updated_at?: string;
|
||||||
|
visibility?: string;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="group flex items-start sm:items-center gap-3 pl-3 py-3 cursor-pointer rounded-xl hover:bg-base-bg-emphasized relative">
|
||||||
|
{/* Icon container */}
|
||||||
|
<div className="w-10 h-10 bg-base-bg rounded-md justify-center items-center flex">
|
||||||
|
<GithubIcon />
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 gap-3 flex-wrap">
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
|
<p className="text-elements-high-em text-sm font-medium tracking-[-0.006em]">
|
||||||
|
{full_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-elements-low-em text-xs">
|
||||||
|
{updated_at && relativeTimeISO(updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{visibility === 'private' && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 px-2 py-1 flex items-center gap-1 rounded-lg text-xs text-orange-600 h-fit">
|
||||||
|
<LockIcon />
|
||||||
|
Private
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MockTemplateCard = ({ icon, name }: { icon: string; name: string }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 hover:bg-base-bg-emphasized rounded-2xl group relative cursor-default">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="px-1 py-1 rounded-xl bg-base-bg border border-border-interactive/10 shadow-card-sm">
|
||||||
|
<TemplateIcon type={icon as TemplateIconType} />
|
||||||
|
</div>
|
||||||
|
{/* Name */}
|
||||||
|
<p className="flex-1 text-left text-sm tracking-tighter text-elements-high-em">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,81 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Chip, IconButton, Spinner } from '@material-tailwind/react';
|
|
||||||
|
|
||||||
import { relativeTimeISO } from '../../../utils/time';
|
|
||||||
import { GitRepositoryDetails } from '../../../types';
|
|
||||||
import { useGQLClient } from '../../../context/GQLClientContext';
|
|
||||||
import { GithubIcon, LockIcon } from 'components/shared/CustomIcon';
|
|
||||||
|
|
||||||
interface ProjectRepoCardProps {
|
|
||||||
repository: GitRepositoryDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
|
|
||||||
const client = useGQLClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
|
||||||
|
|
||||||
const { orgSlug } = useParams();
|
|
||||||
|
|
||||||
const createProject = useCallback(async () => {
|
|
||||||
if (!repository) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
const { addProject } = await client.addProject(orgSlug!, {
|
|
||||||
name: `${repository.owner!.login}-${repository.name}`,
|
|
||||||
prodBranch: repository.default_branch!,
|
|
||||||
repository: repository.full_name,
|
|
||||||
// TODO: Compute template from repo
|
|
||||||
template: 'webapp',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Boolean(addProject)) {
|
|
||||||
setIsLoading(false);
|
|
||||||
navigate(`import?projectId=${addProject.id}`);
|
|
||||||
} else {
|
|
||||||
setIsLoading(false);
|
|
||||||
toast.error('Failed to create project');
|
|
||||||
}
|
|
||||||
}, [client, repository]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="group flex items-center gap-4 text-gray-500 text-xs hover:bg-gray-100 p-2 cursor-pointer"
|
|
||||||
onClick={createProject}
|
|
||||||
>
|
|
||||||
<div className="w-10 h-10 bg-white rounded-md justify-center items-center gap-1.5 inline-flex">
|
|
||||||
<GithubIcon />
|
|
||||||
</div>
|
|
||||||
<div className="grow">
|
|
||||||
<div>
|
|
||||||
<span className="text-black">{repository.full_name}</span>
|
|
||||||
{repository.visibility === 'private' && (
|
|
||||||
<Chip
|
|
||||||
className="normal-case inline ml-6 font-normal text-xs text-xs bg-orange-50 border border-orange-200 text-orange-600 items-center gap-1 inline-flex"
|
|
||||||
size="sm"
|
|
||||||
value="Private"
|
|
||||||
icon={<LockIcon />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p>{repository.updated_at && relativeTimeISO(repository.updated_at)}</p>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<Spinner className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<div className="hidden group-hover:block">
|
|
||||||
<IconButton size="sm" placeholder={''}>
|
|
||||||
{'>'}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectRepoCard;
|
|
@ -0,0 +1,114 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Spinner } from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
|
import { relativeTimeISO } from 'utils/time';
|
||||||
|
import { GitRepositoryDetails } from '../../../../types/types';
|
||||||
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
|
import {
|
||||||
|
ArrowRightCircleIcon,
|
||||||
|
GithubIcon,
|
||||||
|
LockIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { useToast } from 'components/shared/Toast';
|
||||||
|
|
||||||
|
interface ProjectRepoCardProps {
|
||||||
|
repository: GitRepositoryDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
|
||||||
|
repository,
|
||||||
|
}) => {
|
||||||
|
const client = useGQLClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const { orgSlug } = useParams();
|
||||||
|
const { toast, dismiss } = useToast();
|
||||||
|
|
||||||
|
const createProject = useCallback(async () => {
|
||||||
|
if (!repository || !orgSlug) {
|
||||||
|
return toast({
|
||||||
|
id: 'missing-repository-or-org-slug',
|
||||||
|
title: 'Repository or organization slug is missing',
|
||||||
|
variant: 'error',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const { addProject } = await client.addProject(orgSlug, {
|
||||||
|
name: `${repository.owner?.login}-${repository.name}`,
|
||||||
|
prodBranch: repository.default_branch as string,
|
||||||
|
repository: repository.full_name,
|
||||||
|
// TODO: Compute template from repo
|
||||||
|
template: 'webapp',
|
||||||
|
});
|
||||||
|
if (addProject) {
|
||||||
|
navigate(`import?projectId=${addProject.id}`);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
id: 'failed-to-create-project',
|
||||||
|
title: 'Failed to create project',
|
||||||
|
variant: 'error',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error((error as Error).message);
|
||||||
|
toast({
|
||||||
|
id: 'failed-to-create-project',
|
||||||
|
title: 'Failed to create project',
|
||||||
|
variant: 'error',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [client, repository, orgSlug, setIsLoading, navigate, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group flex items-start sm:items-center gap-3 px-3 py-3 cursor-pointer rounded-xl hover:bg-base-bg-emphasized relative"
|
||||||
|
onClick={createProject}
|
||||||
|
>
|
||||||
|
{/* Icon container */}
|
||||||
|
<div className="w-10 h-10 bg-base-bg rounded-md justify-center items-center flex">
|
||||||
|
<GithubIcon />
|
||||||
|
</div>
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 gap-3 flex-wrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-elements-high-em text-sm font-medium tracking-[-0.006em]">
|
||||||
|
{repository.full_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-elements-low-em text-xs">
|
||||||
|
{repository.updated_at && relativeTimeISO(repository.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{repository.visibility === 'private' && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 px-2 py-1 flex items-center gap-1 rounded-lg text-xs text-orange-600 h-fit">
|
||||||
|
<LockIcon />
|
||||||
|
Private
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Right action */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Spinner className="h-4 w-4 absolute right-3" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
size="sm"
|
||||||
|
iconOnly
|
||||||
|
className="sm:group-hover:flex hidden absolute right-3"
|
||||||
|
>
|
||||||
|
<ArrowRightCircleIcon />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './ProjectRepoCard';
|
@ -1,158 +0,0 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Octokit } from 'octokit';
|
|
||||||
import assert from 'assert';
|
|
||||||
import { useDebounce } from 'usehooks-ts';
|
|
||||||
|
|
||||||
import { Button, Typography, Option } from '@material-tailwind/react';
|
|
||||||
|
|
||||||
import SearchBar from '../../SearchBar';
|
|
||||||
import ProjectRepoCard from './ProjectRepoCard';
|
|
||||||
import { GitOrgDetails, GitRepositoryDetails } from '../../../types';
|
|
||||||
import AsyncSelect from '../../shared/AsyncSelect';
|
|
||||||
import { GithubIcon } from 'components/shared/CustomIcon';
|
|
||||||
|
|
||||||
const DEFAULT_SEARCHED_REPO = '';
|
|
||||||
const REPOS_PER_PAGE = 5;
|
|
||||||
|
|
||||||
interface RepositoryListProps {
|
|
||||||
octokit: Octokit;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RepositoryList = ({ octokit }: RepositoryListProps) => {
|
|
||||||
const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO);
|
|
||||||
const [selectedAccount, setSelectedAccount] = useState('');
|
|
||||||
const [orgs, setOrgs] = useState<GitOrgDetails[]>([]);
|
|
||||||
// TODO: Add new type for Git user when required
|
|
||||||
const [gitUser, setGitUser] = useState<GitOrgDetails>();
|
|
||||||
|
|
||||||
const [repositoryDetails, setRepositoryDetails] = useState<
|
|
||||||
GitRepositoryDetails[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUserAndOrgs = async () => {
|
|
||||||
const user = await octokit.rest.users.getAuthenticated();
|
|
||||||
const orgs = await octokit.rest.orgs.listForAuthenticatedUser();
|
|
||||||
setOrgs(orgs.data);
|
|
||||||
setGitUser(user.data);
|
|
||||||
setSelectedAccount(user.data.login);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUserAndOrgs();
|
|
||||||
}, [octokit]);
|
|
||||||
|
|
||||||
const debouncedSearchedRepo = useDebounce<string>(searchedRepo, 500);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRepos = async () => {
|
|
||||||
if (!selectedAccount || !gitUser) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check search input and use GitHub search API
|
|
||||||
if (debouncedSearchedRepo) {
|
|
||||||
let query = `${debouncedSearchedRepo} in:name fork:true`;
|
|
||||||
|
|
||||||
// Check if selected account is an organization
|
|
||||||
if (selectedAccount === gitUser.login) {
|
|
||||||
query = query + ` user:${selectedAccount}`;
|
|
||||||
} else {
|
|
||||||
query = query + ` org:${selectedAccount}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await octokit.rest.search.repos({
|
|
||||||
q: query,
|
|
||||||
per_page: REPOS_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
setRepositoryDetails(result.data.items);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAccount === gitUser.login) {
|
|
||||||
const result = await octokit.rest.repos.listForAuthenticatedUser({
|
|
||||||
per_page: REPOS_PER_PAGE,
|
|
||||||
affiliation: 'owner',
|
|
||||||
});
|
|
||||||
setRepositoryDetails(result.data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedOrg = orgs.find((org) => org.login === selectedAccount);
|
|
||||||
assert(selectedOrg, 'Selected org not found in list');
|
|
||||||
|
|
||||||
const result = await octokit.rest.repos.listForOrg({
|
|
||||||
org: selectedOrg.login,
|
|
||||||
per_page: REPOS_PER_PAGE,
|
|
||||||
type: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
setRepositoryDetails(result.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRepos();
|
|
||||||
}, [selectedAccount, gitUser, orgs, debouncedSearchedRepo]);
|
|
||||||
|
|
||||||
const handleResetFilters = useCallback(() => {
|
|
||||||
assert(gitUser, 'Git user is not available');
|
|
||||||
setSearchedRepo(DEFAULT_SEARCHED_REPO);
|
|
||||||
setSelectedAccount(gitUser.login);
|
|
||||||
}, [gitUser]);
|
|
||||||
|
|
||||||
const accounts = useMemo(() => {
|
|
||||||
if (!octokit || !gitUser) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [gitUser, ...orgs];
|
|
||||||
}, [octokit, orgs, gitUser]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex gap-2 mb-2 items-center">
|
|
||||||
<div className="basis-1/3">
|
|
||||||
<AsyncSelect
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={(value) => setSelectedAccount(value!)}
|
|
||||||
>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<Option key={account.id} value={account.login}>
|
|
||||||
<div className="flex items-center gap-2 justify-start">
|
|
||||||
<GithubIcon /> {account.login}
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</AsyncSelect>
|
|
||||||
</div>
|
|
||||||
<div className="basis-2/3 flex-grow flex items-center">
|
|
||||||
<SearchBar
|
|
||||||
value={searchedRepo}
|
|
||||||
onChange={(event) => setSearchedRepo(event.target.value)}
|
|
||||||
placeholder="Search for repository"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{Boolean(repositoryDetails.length) ? (
|
|
||||||
repositoryDetails.map((repo, key) => {
|
|
||||||
return <ProjectRepoCard repository={repo} key={key} />;
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="mt-4 p-6 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Typography placeholder={''}>No repository found</Typography>
|
|
||||||
<Button
|
|
||||||
className="rounded-full mt-5"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleResetFilters}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
^ Reset filters
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RepositoryList;
|
|
@ -0,0 +1,182 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import assert from 'assert';
|
||||||
|
import { useDebounce } from 'usehooks-ts';
|
||||||
|
|
||||||
|
import { ProjectRepoCard } from 'components/projects/create/ProjectRepoCard';
|
||||||
|
import { GitOrgDetails, GitRepositoryDetails } from '../../../../types/types';
|
||||||
|
import {
|
||||||
|
ChevronGrabberHorizontal,
|
||||||
|
GithubIcon,
|
||||||
|
RefreshIcon,
|
||||||
|
SearchIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { Select, SelectOption } from 'components/shared/Select';
|
||||||
|
import { Input } from 'components/shared/Input';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { useOctokit } from 'context/OctokitContext';
|
||||||
|
|
||||||
|
const DEFAULT_SEARCHED_REPO = '';
|
||||||
|
const REPOS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
export const RepositoryList = () => {
|
||||||
|
const [searchedRepo, setSearchedRepo] = useState(DEFAULT_SEARCHED_REPO);
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<SelectOption>();
|
||||||
|
const [orgs, setOrgs] = useState<GitOrgDetails[]>([]);
|
||||||
|
// TODO: Add new type for Git user when required
|
||||||
|
const [gitUser, setGitUser] = useState<GitOrgDetails>();
|
||||||
|
const { octokit, isAuth } = useOctokit();
|
||||||
|
|
||||||
|
const [repositoryDetails, setRepositoryDetails] = useState<
|
||||||
|
GitRepositoryDetails[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchUserAndOrgs = async () => {
|
||||||
|
try {
|
||||||
|
const user = await octokit.rest.users.getAuthenticated();
|
||||||
|
const orgs = await octokit.rest.orgs.listForAuthenticatedUser();
|
||||||
|
setOrgs(orgs.data);
|
||||||
|
setGitUser(user.data);
|
||||||
|
setSelectedAccount({ label: user.data.login, value: user.data.login });
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled by octokit error hook interceptor in Octokit context
|
||||||
|
console.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isAuth) {
|
||||||
|
fetchUserAndOrgs();
|
||||||
|
}
|
||||||
|
}, [octokit, isAuth]);
|
||||||
|
|
||||||
|
const debouncedSearchedRepo = useDebounce<string>(searchedRepo, 500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchRepos = async () => {
|
||||||
|
if (!selectedAccount || !gitUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check search input and use GitHub search API
|
||||||
|
if (debouncedSearchedRepo) {
|
||||||
|
let query = `${debouncedSearchedRepo} in:name fork:true`;
|
||||||
|
|
||||||
|
// Check if selected account is an organization
|
||||||
|
if (selectedAccount.value === gitUser.login) {
|
||||||
|
query = query + ` user:${selectedAccount}`;
|
||||||
|
} else {
|
||||||
|
query = query + ` org:${selectedAccount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await octokit.rest.search.repos({
|
||||||
|
q: query,
|
||||||
|
per_page: REPOS_PER_PAGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
setRepositoryDetails(result.data.items);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAccount.value === gitUser.login) {
|
||||||
|
const result = await octokit.rest.repos.listForAuthenticatedUser({
|
||||||
|
per_page: REPOS_PER_PAGE,
|
||||||
|
affiliation: 'owner',
|
||||||
|
});
|
||||||
|
setRepositoryDetails(result.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedOrg = orgs.find(
|
||||||
|
(org) => org.login === selectedAccount.value,
|
||||||
|
);
|
||||||
|
assert(selectedOrg, 'Selected org not found in list');
|
||||||
|
|
||||||
|
const result = await octokit.rest.repos.listForOrg({
|
||||||
|
org: selectedOrg.login,
|
||||||
|
per_page: REPOS_PER_PAGE,
|
||||||
|
type: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
setRepositoryDetails(result.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRepos();
|
||||||
|
}, [selectedAccount, gitUser, orgs, debouncedSearchedRepo]);
|
||||||
|
|
||||||
|
const handleResetFilters = useCallback(() => {
|
||||||
|
assert(gitUser, 'Git user is not available');
|
||||||
|
setSearchedRepo(DEFAULT_SEARCHED_REPO);
|
||||||
|
setSelectedAccount({ label: gitUser.login, value: gitUser.login });
|
||||||
|
}, [gitUser]);
|
||||||
|
|
||||||
|
const accounts = useMemo(() => {
|
||||||
|
if (!octokit || !gitUser) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [gitUser, ...orgs];
|
||||||
|
}, [octokit, orgs, gitUser]);
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return accounts.map((account) => ({
|
||||||
|
label: account.login,
|
||||||
|
value: account.login,
|
||||||
|
leftIcon: <GithubIcon />,
|
||||||
|
}));
|
||||||
|
}, [accounts]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
{/* Dropdown and search */}
|
||||||
|
<div className="flex flex-col lg:flex-row gap-0 lg:gap-3 items-center">
|
||||||
|
<div className="lg:basis-1/3 w-full">
|
||||||
|
<Select
|
||||||
|
options={options}
|
||||||
|
placeholder="Select a repository"
|
||||||
|
value={selectedAccount}
|
||||||
|
leftIcon={selectedAccount ? <GithubIcon /> : undefined}
|
||||||
|
rightIcon={<ChevronGrabberHorizontal />}
|
||||||
|
onChange={(value) => setSelectedAccount(value as SelectOption)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="basis-2/3 flex w-full flex-grow">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
value={searchedRepo}
|
||||||
|
placeholder="Search for repository"
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
onChange={(e) => setSearchedRepo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Repository list */}
|
||||||
|
{Boolean(repositoryDetails.length) ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{repositoryDetails.map((repo, index) => (
|
||||||
|
<>
|
||||||
|
<ProjectRepoCard repository={repo} key={index} />
|
||||||
|
{/* Horizontal line */}
|
||||||
|
{index !== repositoryDetails.length - 1 && (
|
||||||
|
<div className="border-b border-border-separator/[0.06] w-full" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 p-6 flex flex-col gap-4 items-center justify-center">
|
||||||
|
<p className="text-elements-high-em font-sans">No repository found</p>
|
||||||
|
<Button
|
||||||
|
variant="tertiary"
|
||||||
|
leftIcon={<RefreshIcon />}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
>
|
||||||
|
Reset filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './RepositoryList';
|
@ -55,9 +55,9 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
}, [orgSlug, dismiss, isGitAuth, navigate, template, toast]);
|
}, [orgSlug, dismiss, isGitAuth, navigate, template, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-3 bg-base-bg-alternate hover:bg-base-bg-emphasized rounded-2xl group relative',
|
'flex items-center gap-3 px-3 py-3 bg-base-bg-alternate hover:bg-base-bg-emphasized rounded-2xl group relative cursor-pointer',
|
||||||
{
|
{
|
||||||
'cursor-default': template?.isComingSoon,
|
'cursor-default': template?.isComingSoon,
|
||||||
},
|
},
|
||||||
@ -86,6 +86,6 @@ export const TemplateCard: React.FC<TemplateCardProps> = ({
|
|||||||
<ArrowRightCircleIcon />
|
<ArrowRightCircleIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { CopyBlock, atomOneLight } from 'react-code-blocks';
|
import { CopyBlock, atomOneLight } from 'react-code-blocks';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
@ -8,7 +7,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogBody,
|
DialogBody,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@material-tailwind/react';
|
} from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
interface AssignDomainProps {
|
interface AssignDomainProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -17,9 +16,9 @@ interface AssignDomainProps {
|
|||||||
|
|
||||||
const AssignDomainDialog = ({ open, handleOpen }: AssignDomainProps) => {
|
const AssignDomainDialog = ({ open, handleOpen }: AssignDomainProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} handler={handleOpen} placeholder={''}>
|
<Dialog open={open} handler={handleOpen}>
|
||||||
<DialogHeader placeholder={''}>Assign Domain</DialogHeader>
|
<DialogHeader>Assign Domain</DialogHeader>
|
||||||
<DialogBody placeholder={''}>
|
<DialogBody>
|
||||||
In order to assign a domain to your production deployments, configure it
|
In order to assign a domain to your production deployments, configure it
|
||||||
in the{' '}
|
in the{' '}
|
||||||
{/* TODO: Fix selection of project settings tab on navigation to domains */}
|
{/* TODO: Fix selection of project settings tab on navigation to domains */}
|
||||||
@ -36,13 +35,12 @@ const AssignDomainDialog = ({ open, handleOpen }: AssignDomainProps) => {
|
|||||||
theme={atomOneLight}
|
theme={atomOneLight}
|
||||||
/>
|
/>
|
||||||
</DialogBody>
|
</DialogBody>
|
||||||
<DialogFooter className="flex justify-start" placeholder={''}>
|
<DialogFooter className="flex justify-start">
|
||||||
<Button
|
<Button
|
||||||
className="rounded-3xl"
|
className="rounded-3xl"
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
color="blue"
|
color="blue"
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<span>Okay</span>
|
<span>Okay</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Deployment,
|
Deployment,
|
||||||
DeploymentStatus,
|
DeploymentStatus,
|
||||||
@ -60,9 +60,27 @@ const DeploymentDetailsCard = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderDeploymentStatus = useCallback(
|
||||||
|
(className?: string) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex lg:flex gap-2 lg:gap-2 2xl:gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent ">
|
<div className={className}>
|
||||||
<div className="flex-1 max-w-[30%] space-y-2">
|
<Tag
|
||||||
|
leftIcon={getIconByDeploymentStatus(deployment.status)}
|
||||||
|
size="xs"
|
||||||
|
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
|
||||||
|
>
|
||||||
|
{deployment.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[deployment.status, deployment.commitHash],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex md:flex-row flex-col gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent relative">
|
||||||
|
<div className="flex-1 flex justify-between w-full md:max-w-[25%] lg:max-w-[28%]">
|
||||||
|
<div className="flex-1 w-full space-y-2 max-w-[90%] sm:max-w-full">
|
||||||
{/* DEPLOYMENT URL */}
|
{/* DEPLOYMENT URL */}
|
||||||
{deployment.url && (
|
{deployment.url && (
|
||||||
<Heading
|
<Heading
|
||||||
@ -80,25 +98,21 @@ const DeploymentDetailsCard = ({
|
|||||||
: 'Preview'}
|
: 'Preview'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DEPLOYMENT STATUS */}
|
|
||||||
<div className="w-[10%] max-w-[110px]">
|
|
||||||
<Tag
|
|
||||||
leftIcon={getIconByDeploymentStatus(deployment.status)}
|
|
||||||
size="xs"
|
|
||||||
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
|
|
||||||
>
|
|
||||||
{deployment.status}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* DEPLOYMENT STATUS */}
|
||||||
|
{renderDeploymentStatus('w-[10%] max-w-[110px] hidden md:flex h-fit')}
|
||||||
|
|
||||||
{/* DEPLOYMENT COMMIT DETAILS */}
|
{/* DEPLOYMENT COMMIT DETAILS */}
|
||||||
<div className="text-sm w-[25%] space-y-2 text-elements-low-em">
|
<div className="flex w-full justify-between md:w-[25%]">
|
||||||
|
<div className="text-sm max-w-[60%] md:max-w-full space-y-2 w-full text-elements-low-em">
|
||||||
<span className="flex gap-1.5 items-center">
|
<span className="flex gap-1.5 items-center">
|
||||||
<BranchStrokeIcon className="h-4 w-4" />
|
<BranchStrokeIcon className="h-4 w-4" />
|
||||||
|
<OverflownText content={deployment.branch}>
|
||||||
{deployment.branch}
|
{deployment.branch}
|
||||||
|
</OverflownText>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex gap-2 items-center">
|
<span className="flex w-full gap-2 items-center">
|
||||||
<CommitIcon />
|
<CommitIcon />
|
||||||
<OverflownText content={deployment.commitMessage}>
|
<OverflownText content={deployment.commitMessage}>
|
||||||
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
|
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
|
||||||
@ -106,14 +120,19 @@ const DeploymentDetailsCard = ({
|
|||||||
</OverflownText>
|
</OverflownText>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{renderDeploymentStatus('flex md:hidden h-fit')}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* DEPLOYMENT INFOs */}
|
{/* DEPLOYMENT INFOs */}
|
||||||
<div className="ml-auto max-w-[312px] w-[30%] gap-1 2xl:gap-5 flex items-center justify-between text-elements-low-em text-sm">
|
<div className="md:ml-auto w-full md:max-w-[312px] md:w-[30%] gap-1 2xl:gap-5 flex items-center justify-between text-elements-low-em text-sm">
|
||||||
<div className="flex w-[70%] items-center gap-0.5 2xl:gap-2 flex-1">
|
<div className="flex md:w-[70%] xl:items-center gap-2 flex-1 xl:flex-row md:flex-col">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<ClockOutlineIcon className="h-4 w-4" />
|
<ClockOutlineIcon className="h-4 w-4" />
|
||||||
<OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}>
|
<OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}>
|
||||||
{relativeTimeMs(deployment.createdAt)}
|
{relativeTimeMs(deployment.createdAt)}
|
||||||
</OverflownText>
|
</OverflownText>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
<div>
|
<div>
|
||||||
<Avatar
|
<Avatar
|
||||||
type="orange"
|
type="orange"
|
||||||
@ -123,16 +142,15 @@ const DeploymentDetailsCard = ({
|
|||||||
// imageSrc={deployment.createdBy.avatarUrl}
|
// imageSrc={deployment.createdBy.avatarUrl}
|
||||||
></Avatar>
|
></Avatar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OverflownText
|
<OverflownText
|
||||||
// className="min-w-[200px]"
|
|
||||||
content={formatAddress(deployment.createdBy?.name ?? '')}
|
content={formatAddress(deployment.createdBy?.name ?? '')}
|
||||||
>
|
>
|
||||||
{formatAddress(deployment.createdBy.name ?? '')}
|
{formatAddress(deployment.createdBy.name ?? '')}
|
||||||
</OverflownText>
|
</OverflownText>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<DeploymentMenu
|
<DeploymentMenu
|
||||||
className="ml-auto"
|
className="ml-auto md:static absolute top-4 right-0"
|
||||||
deployment={deployment}
|
deployment={deployment}
|
||||||
currentDeployment={currentDeployment}
|
currentDeployment={currentDeployment}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
|
@ -1,17 +1,22 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Deployment } from 'gql-client';
|
import { Deployment } from 'gql-client';
|
||||||
|
|
||||||
import { Typography, Chip, Card } from '@material-tailwind/react';
|
import { relativeTimeMs } from 'utils/time';
|
||||||
import { color } from '@material-tailwind/react/types/components/chip';
|
|
||||||
import { relativeTimeMs } from '../../../../utils/time';
|
|
||||||
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
|
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
|
||||||
import { formatAddress } from '../../../../utils/format';
|
import {
|
||||||
|
BranchStrokeIcon,
|
||||||
|
ClockOutlineIcon,
|
||||||
|
CommitIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { Avatar } from 'components/shared/Avatar';
|
||||||
|
import { getInitials } from 'utils/geInitials';
|
||||||
|
import { OverflownText } from 'components/shared/OverflownText';
|
||||||
|
import { Tag, TagProps } from 'components/shared/Tag';
|
||||||
|
|
||||||
interface DeploymentDialogBodyCardProps {
|
interface DeploymentDialogBodyCardProps {
|
||||||
deployment: Deployment;
|
deployment: Deployment;
|
||||||
chip?: {
|
chip?: {
|
||||||
value: string;
|
value: string;
|
||||||
color?: color;
|
type?: TagProps['type'];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,31 +24,54 @@ const DeploymentDialogBodyCard = ({
|
|||||||
chip,
|
chip,
|
||||||
deployment,
|
deployment,
|
||||||
}: DeploymentDialogBodyCardProps) => {
|
}: DeploymentDialogBodyCardProps) => {
|
||||||
|
const commit =
|
||||||
|
deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH) +
|
||||||
|
' ' +
|
||||||
|
deployment.commitMessage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-2 shadow-none" placeholder={''}>
|
<div className="flex flex-col gap-4 px-4 py-4 rounded-xl bg-base-bg-emphasized text-elements-low-em">
|
||||||
{chip && (
|
{chip && (
|
||||||
<Chip
|
<Tag className="w-fit" size="xs" type={chip.type}>
|
||||||
className={`w-fit normal-case font-normal`}
|
{chip.value}
|
||||||
size="sm"
|
</Tag>
|
||||||
value={chip.value}
|
|
||||||
color={chip.color}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{deployment.url && (
|
<div className="flex flex-col gap-3">
|
||||||
<Typography variant="small" className="text-black" placeholder={''}>
|
{/* Title */}
|
||||||
|
<p className="text-sm font-medium text-elements-high-em tracking-[0.006em]">
|
||||||
{deployment.url}
|
{deployment.url}
|
||||||
</Typography>
|
</p>
|
||||||
)}
|
{/* Branch & commit */}
|
||||||
<Typography variant="small" placeholder={''}>
|
<div className="flex items-center gap-6 text-elements-low-em">
|
||||||
^ {deployment.branch} ^{' '}
|
<div className="flex items-center gap-2">
|
||||||
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
|
<BranchStrokeIcon size={16} />
|
||||||
{deployment.commitMessage}
|
<p className="text-sm tracking-[0.006em]">{deployment.branch}</p>
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="small" placeholder={''}>
|
<div className="flex items-center gap-2 w-full">
|
||||||
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
|
<CommitIcon size={16} />
|
||||||
{formatAddress(deployment.createdBy.name ?? '')}
|
<p className="text-sm tracking-[0.006em] max-w-[67.5%] sm:max-w-[80%]">
|
||||||
</Typography>
|
<OverflownText content={commit}>{commit}</OverflownText>
|
||||||
</Card>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-elements-low-em">
|
||||||
|
<ClockOutlineIcon size={16} />
|
||||||
|
<p className="text-sm tracking-[0.006em]">
|
||||||
|
{relativeTimeMs(deployment.createdAt)}
|
||||||
|
</p>
|
||||||
|
<Avatar
|
||||||
|
size={20}
|
||||||
|
type="orange"
|
||||||
|
initials={getInitials(deployment.createdBy.name ?? '')}
|
||||||
|
// TODO: Add avatar image URL
|
||||||
|
// imageSrc={deployment.createdBy.imageUrl}
|
||||||
|
/>
|
||||||
|
<p className="text-sm tracking-[0.006em]">
|
||||||
|
{deployment.createdBy.name ?? 'Unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { Deployment, Domain, Environment, Project } from 'gql-client';
|
import { Deployment, Domain, Environment, Project } from 'gql-client';
|
||||||
import { Button } from 'components/shared/Button';
|
import { Button } from 'components/shared/Button';
|
||||||
@ -15,14 +15,12 @@ import {
|
|||||||
MenuHandler,
|
MenuHandler,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
} from '@material-tailwind/react';
|
} from '@snowballtools/material-tailwind-react-fork';
|
||||||
import { ComponentPropsWithRef } from 'react';
|
import { ComponentPropsWithRef } from 'react';
|
||||||
import ConfirmDialog from '../../../shared/ConfirmDialog';
|
|
||||||
import AssignDomainDialog from './AssignDomainDialog';
|
import AssignDomainDialog from './AssignDomainDialog';
|
||||||
import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
import { Typography } from '@material-tailwind/react';
|
|
||||||
import { useGQLClient } from '../../../../context/GQLClientContext';
|
|
||||||
import { cn } from 'utils/classnames';
|
import { cn } from 'utils/classnames';
|
||||||
|
import { ChangeStateToProductionDialog } from 'components/projects/Dialog/ChangeStateToProductionDialog';
|
||||||
|
|
||||||
interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> {
|
interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> {
|
||||||
deployment: Deployment;
|
deployment: Deployment;
|
||||||
@ -98,11 +96,10 @@ export const DeploymentMenu = ({
|
|||||||
aria-label="Toggle Menu"
|
aria-label="Toggle Menu"
|
||||||
/>
|
/>
|
||||||
</MenuHandler>
|
</MenuHandler>
|
||||||
<MenuList className="text-elements-high-em" placeholder={''}>
|
<MenuList className="text-elements-high-em">
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className="hover:bg-base-bg-emphasized"
|
className="hover:bg-base-bg-emphasized"
|
||||||
disabled={!Boolean(deployment.url)}
|
disabled={!Boolean(deployment.url)}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
className="flex items-center gap-3"
|
className="flex items-center gap-3"
|
||||||
@ -116,7 +113,6 @@ export const DeploymentMenu = ({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
className="hover:bg-base-bg-emphasized flex items-center gap-3"
|
className="hover:bg-base-bg-emphasized flex items-center gap-3"
|
||||||
onClick={() => setAssignDomainDialog(!assignDomainDialog)}
|
onClick={() => setAssignDomainDialog(!assignDomainDialog)}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<GlobeIcon /> Assign domain
|
<GlobeIcon /> Assign domain
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -124,7 +120,6 @@ export const DeploymentMenu = ({
|
|||||||
className="hover:bg-base-bg-emphasized flex items-center gap-3"
|
className="hover:bg-base-bg-emphasized flex items-center gap-3"
|
||||||
onClick={() => setChangeToProduction(!changeToProduction)}
|
onClick={() => setChangeToProduction(!changeToProduction)}
|
||||||
disabled={!(deployment.environment !== Environment.Production)}
|
disabled={!(deployment.environment !== Environment.Production)}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<RocketIcon /> Change to production
|
<RocketIcon /> Change to production
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -138,7 +133,6 @@ export const DeploymentMenu = ({
|
|||||||
deployment.isCurrent
|
deployment.isCurrent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<RefreshIcon /> Redeploy to production
|
<RefreshIcon /> Redeploy to production
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -150,7 +144,6 @@ export const DeploymentMenu = ({
|
|||||||
deployment.environment !== Environment.Production ||
|
deployment.environment !== Environment.Production ||
|
||||||
!Boolean(currentDeployment)
|
!Boolean(currentDeployment)
|
||||||
}
|
}
|
||||||
placeholder={''}
|
|
||||||
>
|
>
|
||||||
<UndoIcon /> Rollback to this version
|
<UndoIcon /> Rollback to this version
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@ -158,106 +151,44 @@ export const DeploymentMenu = ({
|
|||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
{/* Dialogs */}
|
{/* Dialogs */}
|
||||||
<ConfirmDialog
|
<ChangeStateToProductionDialog
|
||||||
dialogTitle="Change to production?"
|
dialogTitle="Change to production?"
|
||||||
handleOpen={() => setChangeToProduction((preVal) => !preVal)}
|
|
||||||
open={changeToProduction}
|
|
||||||
confirmButtonTitle="Change"
|
confirmButtonTitle="Change"
|
||||||
color="blue"
|
handleCancel={() => setChangeToProduction((preVal) => !preVal)}
|
||||||
|
open={changeToProduction}
|
||||||
handleConfirm={async () => {
|
handleConfirm={async () => {
|
||||||
await updateDeployment();
|
await updateDeployment();
|
||||||
setChangeToProduction((preVal) => !preVal);
|
setChangeToProduction((preVal) => !preVal);
|
||||||
}}
|
}}
|
||||||
>
|
deployment={deployment}
|
||||||
<div className="flex flex-col gap-2">
|
domains={prodBranchDomains}
|
||||||
<Typography variant="small" placeholder={''}>
|
/>
|
||||||
Upon confirmation, this deployment will be changed to production.
|
<ChangeStateToProductionDialog
|
||||||
</Typography>
|
|
||||||
<DeploymentDialogBodyCard deployment={deployment} />
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
The new deployment will be associated with these domains:
|
|
||||||
</Typography>
|
|
||||||
{prodBranchDomains.length > 0 &&
|
|
||||||
prodBranchDomains.map((value) => {
|
|
||||||
return (
|
|
||||||
<Typography
|
|
||||||
variant="small"
|
|
||||||
color="blue"
|
|
||||||
key={value.id}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
^ {value.name}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
<ConfirmDialog
|
|
||||||
dialogTitle="Redeploy to production?"
|
dialogTitle="Redeploy to production?"
|
||||||
handleOpen={() => setRedeployToProduction((preVal) => !preVal)}
|
handleCancel={() => setRedeployToProduction((preVal) => !preVal)}
|
||||||
open={redeployToProduction}
|
open={redeployToProduction}
|
||||||
confirmButtonTitle="Redeploy"
|
confirmButtonTitle="Redeploy"
|
||||||
color="blue"
|
|
||||||
handleConfirm={async () => {
|
handleConfirm={async () => {
|
||||||
await redeployToProd();
|
await redeployToProd();
|
||||||
setRedeployToProduction((preVal) => !preVal);
|
setRedeployToProduction((preVal) => !preVal);
|
||||||
}}
|
}}
|
||||||
>
|
deployment={deployment}
|
||||||
<div className="flex flex-col gap-2">
|
domains={deployment.domain ? [deployment.domain] : []}
|
||||||
<Typography variant="small" placeholder={''}>
|
/>
|
||||||
Upon confirmation, new deployment will be created with the same
|
|
||||||
source code as current deployment.
|
|
||||||
</Typography>
|
|
||||||
<DeploymentDialogBodyCard deployment={deployment} />
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
These domains will point to your new deployment:
|
|
||||||
</Typography>
|
|
||||||
{deployment.domain?.name && (
|
|
||||||
<Typography variant="small" color="blue" placeholder={''}>
|
|
||||||
{deployment.domain?.name}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
{Boolean(currentDeployment) && (
|
{Boolean(currentDeployment) && (
|
||||||
<ConfirmDialog
|
<ChangeStateToProductionDialog
|
||||||
dialogTitle="Rollback to this deployment?"
|
dialogTitle="Rollback to this deployment?"
|
||||||
handleOpen={() => setRollbackDeployment((preVal) => !preVal)}
|
handleCancel={() => setRollbackDeployment((preVal) => !preVal)}
|
||||||
open={rollbackDeployment}
|
open={rollbackDeployment}
|
||||||
confirmButtonTitle="Rollback"
|
confirmButtonTitle="Rollback"
|
||||||
color="blue"
|
|
||||||
handleConfirm={async () => {
|
handleConfirm={async () => {
|
||||||
await rollbackDeploymentHandler();
|
await rollbackDeploymentHandler();
|
||||||
setRollbackDeployment((preVal) => !preVal);
|
setRollbackDeployment((preVal) => !preVal);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
Upon confirmation, this deployment will replace your current
|
|
||||||
deployment
|
|
||||||
</Typography>
|
|
||||||
<DeploymentDialogBodyCard
|
|
||||||
deployment={currentDeployment}
|
deployment={currentDeployment}
|
||||||
chip={{
|
newDeployment={deployment}
|
||||||
value: 'Live Deployment',
|
domains={currentDeployment.domain ? [currentDeployment.domain] : []}
|
||||||
color: 'green',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<DeploymentDialogBodyCard
|
|
||||||
deployment={deployment}
|
|
||||||
chip={{
|
|
||||||
value: 'New Deployment',
|
|
||||||
color: 'orange',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
These domains will point to your new deployment:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="small" color="blue" placeholder={''}>
|
|
||||||
^ {currentDeployment.domain?.name}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
)}
|
)}
|
||||||
<AssignDomainDialog
|
<AssignDomainDialog
|
||||||
open={assignDomainDialog}
|
open={assignDomainDialog}
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
|
|
||||||
import { IconButton, Option, Select } from '@material-tailwind/react';
|
import { Input } from 'components/shared/Input';
|
||||||
|
import {
|
||||||
import SearchBar from '../../../SearchBar';
|
CheckRadioOutlineIcon,
|
||||||
import DatePicker from '../../../DatePicker';
|
CrossCircleIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
SearchIcon,
|
||||||
|
TrendingIcon,
|
||||||
|
WarningTriangleIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { DatePicker } from 'components/shared/DatePicker';
|
||||||
|
import { Select, SelectOption } from 'components/shared/Select';
|
||||||
|
import { Value } from 'types/vendor';
|
||||||
|
|
||||||
export enum StatusOptions {
|
export enum StatusOptions {
|
||||||
ALL_STATUS = 'All status',
|
ALL_STATUS = 'All status',
|
||||||
@ -15,8 +22,8 @@ export enum StatusOptions {
|
|||||||
|
|
||||||
export interface FilterValue {
|
export interface FilterValue {
|
||||||
searchedBranch: string;
|
searchedBranch: string;
|
||||||
status: StatusOptions;
|
status: StatusOptions | string;
|
||||||
updateAtRange?: DateRange;
|
updateAtRange?: Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterFormProps {
|
interface FilterFormProps {
|
||||||
@ -27,7 +34,7 @@ interface FilterFormProps {
|
|||||||
const FilterForm = ({ value, onChange }: FilterFormProps) => {
|
const FilterForm = ({ value, onChange }: FilterFormProps) => {
|
||||||
const [searchedBranch, setSearchedBranch] = useState(value.searchedBranch);
|
const [searchedBranch, setSearchedBranch] = useState(value.searchedBranch);
|
||||||
const [selectedStatus, setSelectedStatus] = useState(value.status);
|
const [selectedStatus, setSelectedStatus] = useState(value.status);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>();
|
const [dateRange, setDateRange] = useState<Value>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChange({
|
onChange({
|
||||||
@ -43,46 +50,68 @@ const FilterForm = ({ value, onChange }: FilterFormProps) => {
|
|||||||
setDateRange(value.updateAtRange);
|
setDateRange(value.updateAtRange);
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
|
const getOptionIcon = (status: StatusOptions) => {
|
||||||
|
switch (status) {
|
||||||
|
case StatusOptions.BUILDING:
|
||||||
|
return <LoaderIcon />;
|
||||||
|
case StatusOptions.READY:
|
||||||
|
return <CheckRadioOutlineIcon />;
|
||||||
|
case StatusOptions.ERROR:
|
||||||
|
return <WarningTriangleIcon />;
|
||||||
|
case StatusOptions.ALL_STATUS:
|
||||||
|
default:
|
||||||
|
return <TrendingIcon />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = Object.values(StatusOptions).map((status) => ({
|
||||||
|
label: status,
|
||||||
|
value: status,
|
||||||
|
leftIcon: getOptionIcon(status),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setSearchedBranch('');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid items-center grid-cols-8 gap-2 text-sm text-gray-600">
|
<div className="xl:grid xl:grid-cols-8 flex flex-col xl:gap-3 gap-3">
|
||||||
<div className="col-span-4">
|
<div className="col-span-4 flex items-center">
|
||||||
<SearchBar
|
<Input
|
||||||
placeholder="Search branches"
|
placeholder="Search branches"
|
||||||
|
leftIcon={<SearchIcon />}
|
||||||
|
rightIcon={
|
||||||
|
searchedBranch && <CrossCircleIcon onClick={handleReset} />
|
||||||
|
}
|
||||||
value={searchedBranch}
|
value={searchedBranch}
|
||||||
onChange={(event) => setSearchedBranch(event.target.value)}
|
onChange={(e) => setSearchedBranch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 flex items-center">
|
||||||
<DatePicker mode="range" selected={dateRange} onSelect={setDateRange} />
|
<DatePicker
|
||||||
|
className="w-full"
|
||||||
|
selectRange
|
||||||
|
value={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
onReset={() => setDateRange(undefined)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 relative">
|
<div className="col-span-2 flex items-center">
|
||||||
<Select
|
<Select
|
||||||
value={selectedStatus}
|
leftIcon={getOptionIcon(selectedStatus as StatusOptions)}
|
||||||
onChange={(value) => setSelectedStatus(value as StatusOptions)}
|
options={statusOptions}
|
||||||
|
clearable
|
||||||
placeholder="All status"
|
placeholder="All status"
|
||||||
>
|
value={
|
||||||
{Object.values(StatusOptions).map((status) => (
|
selectedStatus
|
||||||
<Option
|
? { label: selectedStatus, value: selectedStatus }
|
||||||
className={status === StatusOptions.ALL_STATUS ? 'hidden' : ''}
|
: undefined
|
||||||
key={status}
|
}
|
||||||
value={status}
|
onChange={(item) =>
|
||||||
>
|
setSelectedStatus((item as SelectOption).value as StatusOptions)
|
||||||
^ {status}
|
}
|
||||||
</Option>
|
onClear={() => setSelectedStatus('')}
|
||||||
))}
|
/>
|
||||||
</Select>
|
|
||||||
{selectedStatus !== StatusOptions.ALL_STATUS && (
|
|
||||||
<div className="absolute end-1 inset-y-0 my-auto h-8">
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setSelectedStatus(StatusOptions.ALL_STATUS)}
|
|
||||||
className="rounded-full"
|
|
||||||
size="sm"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,17 +1,18 @@
|
|||||||
import React from 'react';
|
import { GitCommitWithBranch } from '../../../../../types/types';
|
||||||
|
|
||||||
import { GitCommitWithBranch } from 'types';
|
|
||||||
import { Heading } from 'components/shared/Heading';
|
import { Heading } from 'components/shared/Heading';
|
||||||
import ActivityCard from './ActivityCard';
|
import ActivityCard from './ActivityCard';
|
||||||
import { Button } from 'components/shared/Button';
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { LoadingIcon } from 'components/shared/CustomIcon';
|
||||||
|
|
||||||
export const Activity = ({
|
export const Activity = ({
|
||||||
|
isLoading,
|
||||||
activities,
|
activities,
|
||||||
}: {
|
}: {
|
||||||
|
isLoading: boolean;
|
||||||
activities: GitCommitWithBranch[];
|
activities: GitCommitWithBranch[];
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 mr-1">
|
<div className="col-span-5 md:col-span-2 mr-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Heading className="text-lg leading-6 font-medium">Activity</Heading>
|
<Heading className="text-lg leading-6 font-medium">Activity</Heading>
|
||||||
<Button variant="tertiary" size="sm">
|
<Button variant="tertiary" size="sm">
|
||||||
@ -19,9 +20,17 @@ export const Activity = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{activities.map((activity, index) => {
|
{isLoading ? (
|
||||||
return <ActivityCard activity={activity} key={`activity-${index}`} />;
|
<div className="grid place-content-center mt-10">
|
||||||
})}
|
<LoadingIcon className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
activities.map((activity, index) => {
|
||||||
|
return (
|
||||||
|
<ActivityCard activity={activity} key={`activity-${index}`} />
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { GitCommitWithBranch } from '../../../../../types';
|
import { GitCommitWithBranch } from '../../../../../types/types';
|
||||||
import { Avatar } from 'components/shared/Avatar';
|
import { Avatar } from 'components/shared/Avatar';
|
||||||
import { Button } from 'components/shared/Button';
|
import { Button } from 'components/shared/Button';
|
||||||
import {
|
import {
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import React from 'react';
|
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { cloneElement } from 'utils/cloneElement';
|
import { cloneElement } from 'utils/cloneElement';
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
|
||||||
import { UseFormRegister } from 'react-hook-form';
|
import { UseFormRegister } from 'react-hook-form';
|
||||||
|
|
||||||
import { Typography, Input, IconButton } from '@material-tailwind/react';
|
|
||||||
|
|
||||||
import { EnvironmentVariablesFormValues } from '../../../../types';
|
import { EnvironmentVariablesFormValues } from '../../../../types';
|
||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { TrashIcon } from 'components/shared/CustomIcon';
|
||||||
|
import { Input } from 'components/shared/Input';
|
||||||
|
|
||||||
interface AddEnvironmentVariableRowProps {
|
interface AddEnvironmentVariableRowProps {
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@ -19,39 +19,24 @@ const AddEnvironmentVariableRow = ({
|
|||||||
isDeleteDisabled,
|
isDeleteDisabled,
|
||||||
}: AddEnvironmentVariableRowProps) => {
|
}: AddEnvironmentVariableRowProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1 p-2">
|
<div className="flex gap-2 py-1 self-stretch items-end">
|
||||||
<div>
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
Key
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
crossOrigin={undefined}
|
size="md"
|
||||||
{...register(`variables.${index}.key`, {
|
{...register(`variables.${index}.key`, {
|
||||||
required: 'Key field cannot be empty',
|
required: 'Key field cannot be empty',
|
||||||
})}
|
})}
|
||||||
|
label={index === 0 ? 'Key' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
Value
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
crossOrigin={undefined}
|
size="md"
|
||||||
|
label={index === 0 ? 'Value' : undefined}
|
||||||
{...register(`variables.${index}.value`, {
|
{...register(`variables.${index}.value`, {
|
||||||
required: 'Value field cannot be empty',
|
required: 'Value field cannot be empty',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button size="md" iconOnly onClick={onDelete} disabled={isDeleteDisabled}>
|
||||||
<div className="self-end">
|
<TrashIcon />
|
||||||
<IconButton
|
</Button>
|
||||||
size="sm"
|
|
||||||
onClick={() => onDelete()}
|
|
||||||
disabled={isDeleteDisabled}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
{'>'}
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,17 +1,13 @@
|
|||||||
import React, { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { AddProjectMemberInput, Permission } from 'gql-client';
|
import { AddProjectMemberInput, Permission } from 'gql-client';
|
||||||
|
|
||||||
import {
|
import { Typography } from '@snowballtools/material-tailwind-react-fork';
|
||||||
Button,
|
|
||||||
Dialog,
|
import { Button } from 'components/shared/Button';
|
||||||
DialogHeader,
|
import { Modal } from 'components/shared/Modal';
|
||||||
DialogBody,
|
import { Input } from 'components/shared/Input';
|
||||||
DialogFooter,
|
import { Checkbox } from 'components/shared/Checkbox';
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
Checkbox,
|
|
||||||
} from '@material-tailwind/react';
|
|
||||||
|
|
||||||
interface AddMemberDialogProp {
|
interface AddMemberDialogProp {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -61,73 +57,47 @@ const AddMemberDialog = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} handler={handleOpen} placeholder={''}>
|
<Modal open={open} onOpenChange={handleOpen}>
|
||||||
<DialogHeader className="flex justify-between" placeholder={''}>
|
<Modal.Content>
|
||||||
<div>Add member</div>
|
<Modal.Header>Add member</Modal.Header>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleOpen}
|
|
||||||
className="mr-1 rounded-3xl"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</Button>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(submitHandler)}>
|
<form onSubmit={handleSubmit(submitHandler)}>
|
||||||
<DialogBody className="flex flex-col gap-2 p-4" placeholder={''}>
|
<Modal.Body className="flex flex-col gap-2 p-4">
|
||||||
<Typography variant="small" placeholder={''}>
|
<Typography variant="small">
|
||||||
We will send an invitation link to this email address.
|
We will send an invitation link to this email address.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="small" placeholder={''}>
|
<Typography variant="small">Email address</Typography>
|
||||||
Email address
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
crossOrigin={undefined}
|
|
||||||
{...register('emailAddress', {
|
{...register('emailAddress', {
|
||||||
required: 'email field cannot be empty',
|
required: 'email field cannot be empty',
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<Typography variant="small" placeholder={''}>
|
<Typography variant="small">Permissions</Typography>
|
||||||
Permissions
|
<Typography variant="small">
|
||||||
</Typography>
|
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
You can change this later if required.
|
You can change this later if required.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
crossOrigin={undefined}
|
|
||||||
label={Permission.View}
|
label={Permission.View}
|
||||||
{...register(`permissions.view`)}
|
{...register(`permissions.view`)}
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
crossOrigin={undefined}
|
|
||||||
label={Permission.Edit}
|
label={Permission.Edit}
|
||||||
{...register(`permissions.edit`)}
|
{...register(`permissions.edit`)}
|
||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
</DialogBody>
|
</Modal.Body>
|
||||||
<DialogFooter className="flex justify-start" placeholder={''}>
|
<Modal.Footer>
|
||||||
<Button
|
<Button onClick={handleOpen} variant="secondary">
|
||||||
variant="outlined"
|
|
||||||
onClick={handleOpen}
|
|
||||||
className="mr-1"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="submit" disabled={!isValid}>
|
||||||
variant="gradient"
|
|
||||||
color="blue"
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
Send invite
|
Send invite
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</Modal.Footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,19 +1,13 @@
|
|||||||
import React, { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import toast from 'react-hot-toast';
|
|
||||||
import { Project } from 'gql-client';
|
|
||||||
|
|
||||||
import {
|
import { useGQLClient } from 'context/GQLClientContext';
|
||||||
Button,
|
import { useToast } from 'components/shared/Toast';
|
||||||
Dialog,
|
import { Modal } from 'components/shared/Modal';
|
||||||
DialogHeader,
|
import { Button } from 'components/shared/Button';
|
||||||
DialogBody,
|
import { Input } from 'components/shared/Input';
|
||||||
DialogFooter,
|
import { Project } from 'gql-client';
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
} from '@material-tailwind/react';
|
|
||||||
import { useGQLClient } from '../../../../context/GQLClientContext';
|
|
||||||
|
|
||||||
interface DeleteProjectDialogProp {
|
interface DeleteProjectDialogProp {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -26,6 +20,7 @@ const DeleteProjectDialog = ({
|
|||||||
handleOpen,
|
handleOpen,
|
||||||
project,
|
project,
|
||||||
}: DeleteProjectDialogProp) => {
|
}: DeleteProjectDialogProp) => {
|
||||||
|
const { toast, dismiss } = useToast();
|
||||||
const { orgSlug } = useParams();
|
const { orgSlug } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useGQLClient();
|
const client = useGQLClient();
|
||||||
@ -46,66 +41,48 @@ const DeleteProjectDialog = ({
|
|||||||
if (deleteProject) {
|
if (deleteProject) {
|
||||||
navigate(`/${orgSlug}`);
|
navigate(`/${orgSlug}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Project not deleted');
|
toast({
|
||||||
|
id: 'project_not_deleted',
|
||||||
|
title: 'Project not deleted',
|
||||||
|
variant: 'error',
|
||||||
|
onDismiss: dismiss,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpen();
|
handleOpen();
|
||||||
}, [client, project, handleOpen]);
|
}, [client, project, handleOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} handler={handleOpen} placeholder={''}>
|
<Modal open={open} onOpenChange={handleOpen}>
|
||||||
<DialogHeader className="flex justify-between" placeholder={''}>
|
<Modal.Content>
|
||||||
<div>Delete project?</div>
|
<Modal.Header>Delete project?</Modal.Header>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleOpen}
|
|
||||||
className="mr-1 rounded-3xl"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
X
|
|
||||||
</Button>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit(deleteProjectHandler)}>
|
<form onSubmit={handleSubmit(deleteProjectHandler)}>
|
||||||
<DialogBody className="flex flex-col gap-2" placeholder={''}>
|
<Modal.Body>
|
||||||
<Typography variant="paragraph" placeholder={''}>
|
|
||||||
Deleting your project is irreversible. Enter your project’s
|
|
||||||
name
|
|
||||||
<span className="bg-blue-100 text-blue-700">({project.name})</span>
|
|
||||||
below to confirm you want to permanently delete it:
|
|
||||||
</Typography>
|
|
||||||
<Input
|
<Input
|
||||||
|
label={
|
||||||
|
"Deleting your project is irreversible. Enter your project's name " +
|
||||||
|
project.name +
|
||||||
|
' below to confirm you want to permanently delete it:'
|
||||||
|
}
|
||||||
id="input"
|
id="input"
|
||||||
crossOrigin={undefined}
|
|
||||||
{...register('projectName', {
|
{...register('projectName', {
|
||||||
required: 'Project name is required',
|
required: 'Project name is required',
|
||||||
validate: (value) => value === project.name,
|
validate: (value) => value === project.name,
|
||||||
})}
|
})}
|
||||||
|
helperText="Deleting your project is irreversible."
|
||||||
/>
|
/>
|
||||||
<Typography variant="small" color="red" placeholder={''}>
|
</Modal.Body>
|
||||||
^ Deleting your project is irreversible.
|
<Modal.Footer className="flex justify-start">
|
||||||
</Typography>
|
<Button onClick={handleOpen} variant="tertiary">
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter className="flex justify-start" placeholder={''}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleOpen}
|
|
||||||
className="mr-1"
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="danger" type="submit" disabled={!isValid}>
|
||||||
variant="gradient"
|
Yes, delete project
|
||||||
color="red"
|
|
||||||
type="submit"
|
|
||||||
disabled={!isValid}
|
|
||||||
placeholder={''}
|
|
||||||
>
|
|
||||||
Yes, Delete project
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</Modal.Footer>
|
||||||
</form>
|
</form>
|
||||||
</Dialog>
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, Collapse, Typography } from '@material-tailwind/react';
|
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
|
||||||
|
|
||||||
import EditEnvironmentVariableRow from './EditEnvironmentVariableRow';
|
import EditEnvironmentVariableRow from './EditEnvironmentVariableRow';
|
||||||
import { Environment, EnvironmentVariable } from 'gql-client';
|
import { Environment, EnvironmentVariable } from 'gql-client';
|
||||||
|
import {
|
||||||
|
ChevronDownSmallIcon,
|
||||||
|
ChevronUpSmallIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
|
||||||
interface DisplayEnvironmentVariablesProps {
|
interface DisplayEnvironmentVariablesProps {
|
||||||
environment: Environment;
|
environment: Environment;
|
||||||
@ -20,23 +24,19 @@ const DisplayEnvironmentVariables = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="flex gap-4 p-2 "
|
className="flex gap-4 p-2"
|
||||||
onClick={() => setOpenCollapse((cur) => !cur)}
|
onClick={() => setOpenCollapse((cur) => !cur)}
|
||||||
>
|
>
|
||||||
<div>^</div>
|
{openCollapse ? <ChevronUpSmallIcon /> : <ChevronDownSmallIcon />}
|
||||||
<div className="grow capitalize">{environment}</div>
|
<div className="grow capitalize">{environment}</div>
|
||||||
<div>{variables.length} variables</div>
|
<div>{variables.length} variables</div>
|
||||||
</div>
|
</div>
|
||||||
<Collapse open={openCollapse}>
|
<Collapse open={openCollapse}>
|
||||||
{variables.length === 0 ? (
|
{variables.length === 0 ? (
|
||||||
<Card className="bg-gray-300 flex items-center p-4" placeholder={''}>
|
<div className="bg-slate-100 rounded-xl flex-col p-4">
|
||||||
<Typography variant="small" className="text-black" placeholder={''}>
|
No environment variables added yet. Once you add them, they'll show
|
||||||
No environment variables added yet.
|
up here.
|
||||||
</Typography>
|
</div>
|
||||||
<Typography variant="small" placeholder={''}>
|
|
||||||
Once you add them, they’ll show up here.
|
|
||||||
</Typography>
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
variables.map((variable: EnvironmentVariable) => {
|
variables.map((variable: EnvironmentVariable) => {
|
||||||
return (
|
return (
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user