Merge branch 'main' into nv-handle-repo-exist-error

This commit is contained in:
Vivian Phung 2024-05-22 10:16:27 -04:00 committed by GitHub
commit 7147611842
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
489 changed files with 23449 additions and 11206 deletions

View File

@ -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

View 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
View File

@ -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
View File

@ -0,0 +1 @@
v20.12.1

439
README.md
View File

@ -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,250 +21,212 @@
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
```toml - For Backend:
[server]
... ```bash
[server.session] cp packages/backend/environments/local.toml.example packages/backend/environments/local.toml
# Secret should be changed to a different random string ```
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7sidnc382"
# Set URL of the frontend app - Production
appOriginUrl = "https://dashboard.snowballtools.com"
# Set to true for session cookies to work behind proxy - Update the following in backend [config file](packages/backend/environments/local.toml)
trustProxy = true
# Set empty domain when using secure connection ```toml
domain = "" [server]
...
[server.session]
# Secret should be changed to a different random string
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7sidnc382"
# Set URL of the frontend app
appOriginUrl = "https://dashboard.snowballtools.com"
# Set to true for session cookies to work behind proxy
trustProxy = true
# Set empty domain when using secure connection
domain = ""
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- Homepage URL: `https://dashboard.snowballtools.com`
- Authorization callback URL: `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
[gitHub]
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)
```toml
[registryConfig]
fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint
restEndpoint = "http://api.laconic.com:1317"
# Use actual port for GQL endpoint
gqlEndpoint = "http://api.laconic.com:9473/api"
# Set private key of account to be used in Laconicd
privateKey = "0wtu92cd4f1y791ezpjwgzzazni4dmd3q3mzqc3t6i6r9v06ji784tey6hwmnn69"
# Set Bond ID to be used for publishing records
bondId = "8xk8c2pb61kajwixpm223zvptr2x2ncajq0vd998p6aqhvqqep2reu6pik245epf"
chainId = "laconic_9000-1"
# Set authority that is existing in the chain
authority = "laconic"
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
```
- Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [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`
- Generate a new client secret after app is created
- 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)
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
```bash
laconic-so --stack fixturenet-laconic-loaded deploy exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
- Run the script to create bond, reserve the authority and set authority bond
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Setup ngrok for GitHub webhooks
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
- Start ngrok and point to backend server endpoint
```bash
ngrok http http://localhost:8000
```
- Look for the forwarding URL in ngrok
```bash
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)
```toml
[gitHub]
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
```
- For Frontend:
```bash
cp packages/frontend/.env.example packages/frontend/.env
```
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env
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
```env
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_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
```
- Production
- Set the following values in [.env](packages/frontend/.env) file
```env
REACT_APP_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
```
- 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
- Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
```
- Build the React application
```bash
yarn build
```
- Use a web server for hosting static built files
```bash
python3 -m http.server -d build 3000
```
- Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_SERVER_URL = 'http://localhost:8000'
```
- 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
- Add project name and select project type as `App`
- Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
```
- The React application will be running in `http://localhost:3000/`
## Development
- Start the backend server
```bash
yarn start:backend
``` ```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml) - Start the frontend
- 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 `https://dashboard.snowballtools.com`
- In "Authorization callback URL", type `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
...
[gitHub]
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)
```toml
...
[registryConfig]
fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint
restEndpoint = "http://api.laconic.com:1317"
# Use actual port for GQL endpoint
gqlEndpoint = "http://api.laconic.com:9473/api"
# Set private key of account to be used in Laconicd
privateKey = "0wtu92cd4f1y791ezpjwgzzazni4dmd3q3mzqc3t6i6r9v06ji784tey6hwmnn69"
# Set Bond ID to be used for publishing records
bondId = "8xk8c2pb61kajwixpm223zvptr2x2ncajq0vd998p6aqhvqqep2reu6pik245epf"
chainId = "laconic_9000-1"
# Set authority that is existing in the chain
authority = "laconic"
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
...
```
- Start the server in `packages/backend`
```bash ```bash
yarn start yarn start
``` ```
### 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
- 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)
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
```bash
laconic-so --stack fixturenet-laconic-loaded deploy exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
- Run the script to create bond, reserve the authority and set authority bond
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Setup ngrok for GitHub webhooks
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
- Start ngrok and point to backend server endpoint
```bash
ngrok http http://localhost:8000
```
- Look for the forwarding URL in ngrok
```bash
...
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)
```toml
...
[gitHub]
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
...
```
- Start the server in `packages/backend`
```bash
yarn start:dev
```
## Frontend
- 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
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
```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
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = cerc-io/image-upload-pwa-example
```
### Frontend Production
- Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
- Set the following values in [.env](packages/frontend/.env) file
```env
# 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
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
```
- Build the React application
```bash
yarn build
```
- Use a web server for hosting static built files
```bash
python3 -m http.server -d build 3000
```
### Frontend Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_SERVER_URL = 'http://localhost:8000'
```
- 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
- Add project name and select project type as `App`
- Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
```
- Start the React application
```bash
yarn start
```
- The React application will be running in `http://localhost:3000/`

View File

@ -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

View File

@ -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"
} }
} }

View File

@ -0,0 +1 @@
v20.12.1

View File

@ -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"
} }

View File

@ -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;
};
} }

View File

@ -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']
}) })

View File

@ -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

View File

@ -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 {

View File

@ -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;

View File

@ -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(
origin: appOriginUrl, cors({
credentials: true origin: appOriginUrl,
})); 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());

View File

@ -161,32 +161,55 @@ 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: {
const [org] = await this.db.getOrganizations({}); name: string
assert(org, 'No organizations exists in database'); email: string
subOrgId: string
ethAddress: string
turnkeyWalletId: string
}): Promise<User> {
const [org] = await this.db.getOrganizations({});
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,
isVerified: true, subOrgId: params.subOrgId,
ethAddress ethAddress: params.ethAddress,
}); isVerified: true,
turnkeyWalletId: params.turnkeyWalletId,
});
await this.db.addUserOrganization({ await this.db.addUserOrganization({
member: user, member: user,
organization: org, organization: org,
role: Role.Owner role: Role.Owner
}); });
}
return user; return user;
} }

View 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;
}

View File

@ -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));

View File

@ -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';

View File

@ -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';

View File

@ -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
``` ```

View File

@ -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

View File

@ -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"
} }
} }

View File

@ -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

View File

@ -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

View 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

View File

@ -0,0 +1,4 @@
record:
deployment: <APPLICATION_DEPLOYMENT_RECORD_ID>
type: ApplicationDeploymentRemovalRequest
version: 1.0.0

View File

@ -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

View 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

View 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"

View File

@ -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=

View File

@ -1 +0,0 @@
build

View 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 },
],
},
};

View File

@ -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"
}
}
}

View File

@ -22,3 +22,4 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
*storybook.log

View File

@ -0,0 +1 @@
v20.12.1

View File

@ -1,3 +0,0 @@
# artifacts
build
coverage

View 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;

View 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;

View File

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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/).

View File

@ -0,0 +1,4 @@
{
"projectId": "Project:663d04870db27ed66a48e466",
"zip": true
}

View 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>

View File

@ -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

View File

@ -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"
} }
} }

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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';

View File

@ -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;

View File

@ -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,
}, },
{ {

View 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>
);
};

View File

@ -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>

View File

@ -1,4 +1,3 @@
import React from 'react';
import { import {
default as ReactDropdown, default as ReactDropdown,
Option as ReactDropdownOption, Option as ReactDropdownOption,

View File

@ -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&nbsp;</span>} {formatTime.days !== 0 && <span>{formatTime.days}d&nbsp;</span>}
{formatTime.hours !== 0 && <span>{formatTime.hours}h&nbsp;</span>} {formatTime.hours !== 0 && <span>{formatTime.hours}h&nbsp;</span>}
{formatTime.minutes !== 0 && <span>{formatTime.minutes}m&nbsp;</span>} {formatTime.minutes !== 0 && <span>{formatTime.minutes}m&nbsp;</span>}

View File

@ -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" />;
}; };

View 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>
);
};

View File

@ -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>

View File

@ -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';

View File

@ -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 };

View 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>
);
};

View File

@ -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&apos;ll have to start the process
from scratch.
</p>
</ConfirmDialog>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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',

View File

@ -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()}>
<p className={theme.title()}>{project.name}</p> <Tooltip content={project.name}>
<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>

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -0,0 +1,2 @@
export * from './ProjectSearchBar';
export * from './ProjectSearchBarDialog';

View File

@ -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> */}

View File

@ -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,63 +32,58 @@ 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 ...
^&nbsp; </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} size="sm"
variant="outlined" variant="tertiary"
size="sm" leftIcon={<WarningIcon size={16} />}
placeholder={''}
>
^ Cancel
</Button>
</div>
<ConfirmDialog
dialogTitle="Cancel deployment?"
handleOpen={handleOpen}
open={open}
confirmButtonTitle="Yes, Cancel deployment"
handleConfirm={handleCancel}
color="red"
> >
<Typography variant="small" placeholder={''}> Cancel
This will halt the deployment and you will have to start the process </Button>
from scratch. <CancelDeploymentDialog
</Typography> handleCancel={handleOpen}
</ConfirmDialog> open={open}
handleConfirm={handleCancel}
/>
</div>
<div>
<DeployStep
title="Building"
status={DeployStatus.COMPLETE}
step="1"
processTime="72000"
/>
<DeployStep
title="Deployment summary"
status={DeployStatus.PROCESSING}
step="2"
startTime={Date.now().toString()}
/>
<DeployStep
title="Running checks"
status={DeployStatus.NOT_STARTED}
step="3"
/>
<DeployStep
title="Assigning domains"
status={DeployStatus.NOT_STARTED}
step="4"
/>
</div> </div>
<DeployStep
title="Building"
status={DeployStatus.COMPLETE}
step="1"
processTime="72000"
/>
<DeployStep
title="Deployment summary"
status={DeployStatus.PROCESSING}
step="2"
startTime={Date.now().toString()}
/>
<DeployStep
title="Running checks"
status={DeployStatus.NOT_STARTED}
step="3"
/>
<DeployStep
title="Assigning domains"
status={DeployStatus.NOT_STARTED}
step="4"
/>
</div> </div>
); );
}; };

View File

@ -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>} <button
{status === DeployStatus.PROCESSING && <div>O</div>} className={cn(
{status === DeployStatus.COMPLETE && ( 'flex justify-between w-full py-5 gap-2',
<div> disableCollapse && 'cursor-auto',
<button )}
onClick={() => { tabIndex={disableCollapse ? -1 : undefined}
setCollapse(!collapse); onClick={() => {
}} if (!disableCollapse) {
> setIsOpen((val) => !val);
{collapse ? '-' : '+'} }
</button> }}
>
<div className={cn('grow flex items-center gap-3')}>
{/* 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>
)}
{status === DeployStatus.PROCESSING && (
<LoaderIcon className="animate-spin text-elements-link" />
)}
{status === DeployStatus.COMPLETE && (
<div className="text-controls-primary">
{!isOpen && <PlusIcon size={24} />}
{isOpen && <MinusCircleIcon size={24} />}
</div>
)}
</div>
{/* 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> </div>
)} )}
<div className="grow">{title}</div>
{status === DeployStatus.PROCESSING && (
<>
^<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
</>
)}
{status === DeployStatus.COMPLETE && ( {status === DeployStatus.COMPLETE && (
<> <div className="flex items-center gap-1.5">
^<FormatMillisecond time={Number(processTime)} />{' '} <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>
)} )}
</div> </button>
<Collapse open={collapse}>
<div className="p-2 text-sm text-gray-500 h-36 overflow-y-scroll"> {/* 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>

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './ProjectRepoCard';

View File

@ -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;

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './RepositoryList';

View File

@ -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>
); );
}; };

View File

@ -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>

View File

@ -1,4 +1,4 @@
import React from 'react'; import { useCallback } from 'react';
import { import {
Deployment, Deployment,
DeploymentStatus, DeploymentStatus,
@ -60,79 +60,97 @@ const DeploymentDetailsCard = ({
} }
}; };
return ( const renderDeploymentStatus = useCallback(
<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 "> (className?: string) => {
<div className="flex-1 max-w-[30%] space-y-2"> return (
{/* DEPLOYMENT URL */} <div className={className}>
{deployment.url && ( <Tag
<Heading leftIcon={getIconByDeploymentStatus(deployment.status)}
className="text-sm font-medium text-elements-high-em tracking-tight" size="xs"
as="h2" type={STATUS_COLORS[deployment.status] ?? 'neutral'}
> >
<OverflownText content={deployment.url}> {deployment.status}
{deployment.url} </Tag>
</OverflownText> </div>
</Heading> );
)} },
<span className="text-sm text-elements-low-em tracking-tight"> [deployment.status, deployment.commitHash],
{deployment.environment === Environment.Production );
? `Production ${deployment.isCurrent ? '(Current)' : ''}`
: 'Preview'} return (
</span> <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 && (
<Heading
className="text-sm font-medium text-elements-high-em tracking-tight"
as="h2"
>
<OverflownText content={deployment.url}>
{deployment.url}
</OverflownText>
</Heading>
)}
<span className="text-sm text-elements-low-em tracking-tight">
{deployment.environment === Environment.Production
? `Production ${deployment.isCurrent ? '(Current)' : ''}`
: 'Preview'}
</span>
</div>
</div> </div>
{/* DEPLOYMENT STATUS */} {/* DEPLOYMENT STATUS */}
<div className="w-[10%] max-w-[110px]"> {renderDeploymentStatus('w-[10%] max-w-[110px] hidden md:flex h-fit')}
<Tag
leftIcon={getIconByDeploymentStatus(deployment.status)}
size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
>
{deployment.status}
</Tag>
</div>
{/* 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%]">
<span className="flex gap-1.5 items-center"> <div className="text-sm max-w-[60%] md:max-w-full space-y-2 w-full text-elements-low-em">
<BranchStrokeIcon className="h-4 w-4" /> <span className="flex gap-1.5 items-center">
{deployment.branch} <BranchStrokeIcon className="h-4 w-4" />
</span> <OverflownText content={deployment.branch}>
<span className="flex gap-2 items-center"> {deployment.branch}
<CommitIcon /> </OverflownText>
<OverflownText content={deployment.commitMessage}> </span>
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '} <span className="flex w-full gap-2 items-center">
{deployment.commitMessage} <CommitIcon />
</OverflownText> <OverflownText content={deployment.commitMessage}>
</span> {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
{deployment.commitMessage}
</OverflownText>
</span>
</div>
{renderDeploymentStatus('flex md:hidden h-fit')}
</div> </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">
<ClockOutlineIcon className="h-4 w-4" /> <div className="flex gap-2 items-center">
<OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}> <ClockOutlineIcon className="h-4 w-4" />
{relativeTimeMs(deployment.createdAt)} <OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}>
</OverflownText> {relativeTimeMs(deployment.createdAt)}
<div> </OverflownText>
<Avatar </div>
type="orange" <div className="flex gap-2 items-center">
initials={getInitials(deployment.createdBy.name ?? '')} <div>
className="lg:size-5 2xl:size-6" <Avatar
// TODO: Add avatarUrl type="orange"
// imageSrc={deployment.createdBy.avatarUrl} initials={getInitials(deployment.createdBy.name ?? '')}
></Avatar> className="lg:size-5 2xl:size-6"
// TODO: Add avatarUrl
// imageSrc={deployment.createdBy.avatarUrl}
></Avatar>
</div>
<OverflownText
content={formatAddress(deployment.createdBy?.name ?? '')}
>
{formatAddress(deployment.createdBy.name ?? '')}
</OverflownText>
</div> </div>
<OverflownText
// className="min-w-[200px]"
content={formatAddress(deployment.createdBy?.name ?? '')}
>
{formatAddress(deployment.createdBy.name ?? '')}
</OverflownText>
</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}

View File

@ -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>
); );
}; };

View File

@ -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);
}} }}
> deployment={currentDeployment}
<div className="flex flex-col gap-2"> newDeployment={deployment}
<Typography variant="small" placeholder={''}> domains={currentDeployment.domain ? [currentDeployment.domain] : []}
Upon confirmation, this deployment will replace your current />
deployment
</Typography>
<DeploymentDialogBodyCard
deployment={currentDeployment}
chip={{
value: 'Live Deployment',
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}

View File

@ -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>
); );

View File

@ -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>
); );

View File

@ -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 {

View File

@ -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';

View File

@ -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> <Input
<Typography variant="small" placeholder={''}> size="md"
Key {...register(`variables.${index}.key`, {
</Typography> required: 'Key field cannot be empty',
<Input })}
crossOrigin={undefined} label={index === 0 ? 'Key' : undefined}
{...register(`variables.${index}.key`, { />
required: 'Key field cannot be empty', <Input
})} size="md"
/> label={index === 0 ? 'Value' : undefined}
</div> {...register(`variables.${index}.value`, {
<div> required: 'Value field cannot be empty',
<Typography variant="small" placeholder={''}> })}
Value />
</Typography> <Button size="md" iconOnly onClick={onDelete} disabled={isDeleteDisabled}>
<Input <TrashIcon />
crossOrigin={undefined} </Button>
{...register(`variables.${index}.value`, {
required: 'Value field cannot be empty',
})}
/>
</div>
<div className="self-end">
<IconButton
size="sm"
onClick={() => onDelete()}
disabled={isDeleteDisabled}
placeholder={''}
>
{'>'}
</IconButton>
</div>
</div> </div>
); );
}; };

View File

@ -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 <form onSubmit={handleSubmit(submitHandler)}>
variant="outlined" <Modal.Body className="flex flex-col gap-2 p-4">
onClick={handleOpen} <Typography variant="small">
className="mr-1 rounded-3xl" We will send an invitation link to this email address.
placeholder={''} </Typography>
> <Typography variant="small">Email address</Typography>
X <Input
</Button> type="email"
</DialogHeader> {...register('emailAddress', {
<form onSubmit={handleSubmit(submitHandler)}> required: 'email field cannot be empty',
<DialogBody className="flex flex-col gap-2 p-4" placeholder={''}> })}
<Typography variant="small" placeholder={''}> />
We will send an invitation link to this email address. <Typography variant="small">Permissions</Typography>
</Typography> <Typography variant="small">
<Typography variant="small" placeholder={''}> You can change this later if required.
Email address </Typography>
</Typography> <Checkbox
<Input label={Permission.View}
type="email" {...register(`permissions.view`)}
crossOrigin={undefined} color="blue"
{...register('emailAddress', { />
required: 'email field cannot be empty', <Checkbox
})} label={Permission.Edit}
/> {...register(`permissions.edit`)}
<Typography variant="small" placeholder={''}> color="blue"
Permissions />
</Typography> </Modal.Body>
<Typography variant="small" placeholder={''}> <Modal.Footer>
You can change this later if required. <Button onClick={handleOpen} variant="secondary">
</Typography> Cancel
<Checkbox </Button>
crossOrigin={undefined} <Button type="submit" disabled={!isValid}>
label={Permission.View} Send invite
{...register(`permissions.view`)} </Button>
color="blue" </Modal.Footer>
/> </form>
<Checkbox </Modal.Content>
crossOrigin={undefined} </Modal>
label={Permission.Edit}
{...register(`permissions.edit`)}
color="blue"
/>
</DialogBody>
<DialogFooter className="flex justify-start" placeholder={''}>
<Button
variant="outlined"
onClick={handleOpen}
className="mr-1"
placeholder={''}
>
Cancel
</Button>
<Button
variant="gradient"
color="blue"
type="submit"
disabled={!isValid}
placeholder={''}
>
Send invite
</Button>
</DialogFooter>
</form>
</Dialog>
); );
}; };

View File

@ -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 <form onSubmit={handleSubmit(deleteProjectHandler)}>
variant="outlined" <Modal.Body>
onClick={handleOpen} <Input
className="mr-1 rounded-3xl" label={
placeholder={''} "Deleting your project is irreversible. Enter your project's name " +
> project.name +
X ' below to confirm you want to permanently delete it:'
</Button> }
</DialogHeader> id="input"
<form onSubmit={handleSubmit(deleteProjectHandler)}> {...register('projectName', {
<DialogBody className="flex flex-col gap-2" placeholder={''}> required: 'Project name is required',
<Typography variant="paragraph" placeholder={''}> validate: (value) => value === project.name,
Deleting your project is irreversible. Enter your projects })}
name&nbsp; helperText="Deleting your project is irreversible."
<span className="bg-blue-100 text-blue-700">({project.name})</span> />
&nbsp;below to confirm you want to permanently delete it: </Modal.Body>
</Typography> <Modal.Footer className="flex justify-start">
<Input <Button onClick={handleOpen} variant="tertiary">
id="input" Cancel
crossOrigin={undefined} </Button>
{...register('projectName', { <Button variant="danger" type="submit" disabled={!isValid}>
required: 'Project name is required', Yes, delete project
validate: (value) => value === project.name, </Button>
})} </Modal.Footer>
/> </form>
<Typography variant="small" color="red" placeholder={''}> </Modal.Content>
^ Deleting your project is irreversible. </Modal>
</Typography>
</DialogBody>
<DialogFooter className="flex justify-start" placeholder={''}>
<Button
variant="outlined"
onClick={handleOpen}
className="mr-1"
placeholder={''}
>
Cancel
</Button>
<Button
variant="gradient"
color="red"
type="submit"
disabled={!isValid}
placeholder={''}
>
Yes, Delete project
</Button>
</DialogFooter>
</form>
</Dialog>
); );
}; };

View File

@ -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, theyll 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