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

113
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,32 +21,20 @@
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`
- Configuration Files
- For Backend:
```bash
cp packages/backend/environments/local.toml.example packages/backend/environments/local.toml
```
- Production
- Update the following in backend [config file](packages/backend/environments/local.toml) - Update the following in backend [config file](packages/backend/environments/local.toml)
```toml ```toml
@ -63,24 +52,21 @@
``` ```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) - [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- In "Homepage URL", type `https://dashboard.snowballtools.com` - Homepage URL: `https://dashboard.snowballtools.com`
- In "Authorization callback URL", type `https://dashboard.snowballtools.com/organization/projects/create` - Authorization callback URL: `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created - Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml ```toml
...
[gitHub] [gitHub]
webhookUrl = "https://api.snowballtools.com" webhookUrl = "https://api.snowballtools.com"
...
``` ```
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml) - Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
```toml ```toml
...
[registryConfig] [registryConfig]
fetchDeploymentRecordDelay = 5000 fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint # Use actual port for REST endpoint
@ -98,21 +84,14 @@
amount = "200000" amount = "200000"
denom = "aphoton" denom = "aphoton"
gas = "750000" gas = "750000"
...
``` ```
- Start the server in `packages/backend` - Development
```bash
yarn start
```
### Backend Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) - [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- In "Homepage URL", type `http://localhost:3000` - Homepage URL: `http://localhost:3000`
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create` - Authorization callback URL: `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created - Generate a new client secret after app is created
- Setup Laconicd - Setup Laconicd
@ -159,38 +138,20 @@
- Look for the forwarding URL in ngrok - Look for the forwarding URL in ngrok
```bash ```bash
...
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000 Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
...
``` ```
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml ```toml
...
[gitHub] [gitHub]
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app" webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
...
``` ```
- Start the server in `packages/backend` - For Frontend:
```bash ```bash
yarn start:dev cp packages/frontend/.env.example packages/frontend/.env
```
## Frontend
- 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 - Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
@ -202,22 +163,16 @@
- Set `REACT_APP_GITHUB_PWA_TEMPLATE_REPO` and `REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file - Set `REACT_APP_GITHUB_PWA_TEMPLATE_REPO` and `REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env ```env
# Set actual owner/name of the template repo that will be used for creating new repo REACT_APP_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo
REACT_APP_GITHUB_PWA_TEMPLATE_REPO = cerc-io/test-progressive-web-app REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo
REACT_APP_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = cerc-io/image-upload-pwa-example
``` ```
### Frontend Production - Production
- Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
- Set the following values in [.env](packages/frontend/.env) file - Set the following values in [.env](packages/frontend/.env) file
```env ```env
# Backend server endpoint REACT_APP_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
REACT_APP_SERVER_URL = 'https://api.snowballtools.com'
``` ```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID - Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
@ -242,7 +197,7 @@
python3 -m http.server -d build 3000 python3 -m http.server -d build 3000
``` ```
### Frontend Development - Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend` - Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
@ -260,10 +215,18 @@
REACT_APP_WALLET_CONNECT_ID = <Project_ID> REACT_APP_WALLET_CONNECT_ID = <Project_ID>
``` ```
- Start the React application - The React application will be running in `http://localhost:3000/`
## Development
- Start the backend server
```bash
yarn start:backend
```
- Start the frontend
```bash ```bash
yarn start yarn start
``` ```
- The React application will be running in `http://localhost:3000/`

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(
cors({
origin: appOriginUrl, origin: appOriginUrl,
credentials: true credentials: true,
})); }),
);
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
// TODO: Set cookie maxAge and handle cookie expiry in frontend
// maxAge: SESSION_COOKIE_MAX_AGE,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
};
if (domain) {
sessionOptions.cookie!.domain = domain;
}
if (trustProxy) { if (trustProxy) {
// trust first proxy // trust first proxy
@ -101,7 +85,14 @@ export const createAndStartServer = async (
} }
app.use( app.use(
session(sessionOptions) cookieSession({
secret: secret,
secure: new URL(appOriginUrl).protocol === 'https:',
// 23 hours (less than 24 hours to avoid sessionSigs expiration issues)
maxAge: 23 * 60 * 60 * 1000,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax',
domain: domain || undefined,
}),
); );
server.applyMiddleware({ server.applyMiddleware({
@ -109,8 +100,8 @@ export const createAndStartServer = async (
path: gqlPath, path: gqlPath,
cors: { cors: {
origin: [appOriginUrl], origin: [appOriginUrl],
credentials: true credentials: true,
} },
}); });
app.use(express.json()); app.use(express.json());

View File

@ -161,24 +161,48 @@ export class Service {
}); });
} }
async loadOrCreateUser (ethAddress: string): Promise<User> { async getUserByEmail(email: string): Promise<User | null> {
// Get user by ETH address return await this.db.getUser({
let user = await this.db.getUser({ where: {
email
}
});
}
async getUserBySubOrgId(subOrgId: string): Promise<User | null> {
return await this.db.getUser({
where: {
subOrgId
}
});
}
async getUserByEthAddress (ethAddress: string): Promise<User | null> {
return await this.db.getUser({
where: { where: {
ethAddress ethAddress
} }
}); });
}
if (!user) { async createUser (params: {
name: string
email: string
subOrgId: string
ethAddress: string
turnkeyWalletId: string
}): Promise<User> {
const [org] = await this.db.getOrganizations({}); const [org] = await this.db.getOrganizations({});
assert(org, 'No organizations exists in database'); assert(org, 'No organizations exists in database');
// Create user with new address // Create user with new address
user = await this.db.addUser({ const user = await this.db.addUser({
email: `${ethAddress}@example.com`, email: params.email,
name: ethAddress, name: params.name,
subOrgId: params.subOrgId,
ethAddress: params.ethAddress,
isVerified: true, isVerified: true,
ethAddress turnkeyWalletId: params.turnkeyWalletId,
}); });
await this.db.addUserOrganization({ await this.db.addUserOrganization({
@ -186,7 +210,6 @@ export class Service {
organization: org, organization: org,
role: Role.Owner role: Role.Owner
}); });
}
return user; return user;
} }

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()}>
<Tooltip content={project.name}>
<p className={theme.title()}>{project.name}</p> <p className={theme.title()}>{project.name}</p>
</Tooltip>
<p className={theme.description()}> <p className={theme.description()}>
{project.deployments[0]?.domain?.name ?? 'No domain'} {project.deployments[0]?.domain?.name ?? 'No domain'}
</p> </p>
@ -92,11 +91,9 @@ export const ProjectCard = ({
<HorizontalDotIcon /> <HorizontalDotIcon />
</Button> </Button>
</MenuHandler> </MenuHandler>
<MenuList placeholder={''}> <MenuList>
<MenuItem placeholder={''}>Project settings</MenuItem> <MenuItem>Project settings</MenuItem>
<MenuItem className="text-red-500" placeholder={''}> <MenuItem className="text-red-500">Delete project</MenuItem>
Delete project
</MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
</div> </div>
@ -121,7 +118,7 @@ export const ProjectCard = ({
<div className={theme.deploymentText()}> <div className={theme.deploymentText()}>
{hasDeployment ? ( {hasDeployment ? (
<> <>
<GitHubLogo /> <GithubLogoIcon />
<span>{relativeTimeMs(project.deployments[0].createdAt)} on</span> <span>{relativeTimeMs(project.deployments[0].createdAt)} on</span>
<BranchIcon /> <BranchIcon />
<span>{project.deployments[0].branch}</span> <span>{project.deployments[0].branch}</span>

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,41 +32,35 @@ const Deploy = () => {
}, []); }, []);
return ( return (
<div> <div className="space-y-7">
<div className="flex justify-between mb-6"> <div className="flex justify-between">
<div> <div className="space-y-1.5">
<h4>Deployment started ...</h4> <Heading as="h4" className="md:text-lg font-medium">
<div className="flex"> Deployment started ...
^&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}
variant="outlined"
size="sm" size="sm"
placeholder={''} variant="tertiary"
leftIcon={<WarningIcon size={16} />}
> >
^ Cancel Cancel
</Button> </Button>
</div> <CancelDeploymentDialog
<ConfirmDialog handleCancel={handleOpen}
dialogTitle="Cancel deployment?"
handleOpen={handleOpen}
open={open} open={open}
confirmButtonTitle="Yes, Cancel deployment"
handleConfirm={handleCancel} handleConfirm={handleCancel}
color="red" />
>
<Typography variant="small" placeholder={''}>
This will halt the deployment and you will have to start the process
from scratch.
</Typography>
</ConfirmDialog>
</div> </div>
<div>
<DeployStep <DeployStep
title="Building" title="Building"
status={DeployStatus.COMPLETE} status={DeployStatus.COMPLETE}
@ -89,6 +84,7 @@ const Deploy = () => {
step="4" step="4"
/> />
</div> </div>
</div>
); );
}; };

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>}
{status === DeployStatus.PROCESSING && <div>O</div>}
{status === DeployStatus.COMPLETE && (
<div>
<button <button
className={cn(
'flex justify-between w-full py-5 gap-2',
disableCollapse && 'cursor-auto',
)}
tabIndex={disableCollapse ? -1 : undefined}
onClick={() => { onClick={() => {
setCollapse(!collapse); if (!disableCollapse) {
setIsOpen((val) => !val);
}
}} }}
> >
{collapse ? '-' : '+'} <div className={cn('grow flex items-center gap-3')}>
</button> {/* Icon */}
<div className="w-6 h-6 grid place-content-center">
{status === DeployStatus.NOT_STARTED && (
<div className="grid place-content-center w-6 h-6 rounded-[48px] bg-base-bg-emphasized font-mono text-xs">
{step}
</div> </div>
)} )}
<div className="grow">{title}</div>
{status === DeployStatus.PROCESSING && ( {status === DeployStatus.PROCESSING && (
<> <LoaderIcon className="animate-spin text-elements-link" />
^<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
</>
)} )}
{status === DeployStatus.COMPLETE && ( {status === DeployStatus.COMPLETE && (
<> <div className="text-controls-primary">
^<FormatMillisecond time={Number(processTime)} />{' '} {!isOpen && <PlusIcon size={24} />}
</> {isOpen && <MinusCircleIcon size={24} />}
</div>
)} )}
</div> </div>
<Collapse open={collapse}>
<div className="p-2 text-sm text-gray-500 h-36 overflow-y-scroll"> {/* Title */}
<span
className={cn(
'text-left text-sm md:text-base',
status === DeployStatus.PROCESSING && 'text-elements-link',
)}
>
{title}
</span>
</div>
{/* Timer */}
{status === DeployStatus.PROCESSING && (
<div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-low-em" />
<Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
</div>
)}
{status === DeployStatus.COMPLETE && (
<div className="flex items-center gap-1.5">
<div className="w-4.5 h-4.5 grid place-content-center">
<CheckRoundFilledIcon
className="text-elements-success"
size={15}
/>
</div>
<FormatMillisecond time={Number(processTime)} />{' '}
</div>
)}
</button>
{/* Collapsible */}
<Collapse open={isOpen}>
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
{/* Logs */}
{processLogs.map((log, key) => { {processLogs.map((log, key) => {
return ( return (
<Typography <p className="font-mono" key={key}>
variant="small"
color="gray"
key={key}
placeholder={''}
>
{log} {log}
</Typography> </p>
); );
})} })}
<div className="sticky bottom-0 left-1/2 flex justify-center">
{/* End of logs ref used for hiding gradient overlay */}
<div ref={ref} />
{/* Overflow gradient overlay */}
{!hideGradientOverlay && (
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
)}
{/* Copy log button */}
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
<Button <Button
size="sm" size="xs"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(processLogs.join('\n')); navigator.clipboard.writeText(processLogs.join('\n'));
toast.success('Logs copied'); toast({
title: 'Logs copied',
variant: 'success',
id: 'logs',
onDismiss: dismiss,
});
}} }}
color="blue" leftIcon={<CopyIcon size={16} />}
placeholder={''}
> >
^ Copy log Copy log
</Button> </Button>
</div> </div>
</div> </div>

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,9 +60,27 @@ const DeploymentDetailsCard = ({
} }
}; };
const renderDeploymentStatus = useCallback(
(className?: string) => {
return ( return (
<div className="flex lg:flex gap-2 lg:gap-2 2xl:gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent "> <div className={className}>
<div className="flex-1 max-w-[30%] space-y-2"> <Tag
leftIcon={getIconByDeploymentStatus(deployment.status)}
size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
>
{deployment.status}
</Tag>
</div>
);
},
[deployment.status, deployment.commitHash],
);
return (
<div className="flex md:flex-row flex-col gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent relative">
<div className="flex-1 flex justify-between w-full md:max-w-[25%] lg:max-w-[28%]">
<div className="flex-1 w-full space-y-2 max-w-[90%] sm:max-w-full">
{/* DEPLOYMENT URL */} {/* DEPLOYMENT URL */}
{deployment.url && ( {deployment.url && (
<Heading <Heading
@ -80,25 +98,21 @@ const DeploymentDetailsCard = ({
: 'Preview'} : 'Preview'}
</span> </span>
</div> </div>
{/* DEPLOYMENT STATUS */}
<div className="w-[10%] max-w-[110px]">
<Tag
leftIcon={getIconByDeploymentStatus(deployment.status)}
size="xs"
type={STATUS_COLORS[deployment.status] ?? 'neutral'}
>
{deployment.status}
</Tag>
</div> </div>
{/* DEPLOYMENT STATUS */}
{renderDeploymentStatus('w-[10%] max-w-[110px] hidden md:flex h-fit')}
{/* DEPLOYMENT COMMIT DETAILS */} {/* DEPLOYMENT COMMIT DETAILS */}
<div className="text-sm w-[25%] space-y-2 text-elements-low-em"> <div className="flex w-full justify-between md:w-[25%]">
<div className="text-sm max-w-[60%] md:max-w-full space-y-2 w-full text-elements-low-em">
<span className="flex gap-1.5 items-center"> <span className="flex gap-1.5 items-center">
<BranchStrokeIcon className="h-4 w-4" /> <BranchStrokeIcon className="h-4 w-4" />
<OverflownText content={deployment.branch}>
{deployment.branch} {deployment.branch}
</OverflownText>
</span> </span>
<span className="flex gap-2 items-center"> <span className="flex w-full gap-2 items-center">
<CommitIcon /> <CommitIcon />
<OverflownText content={deployment.commitMessage}> <OverflownText content={deployment.commitMessage}>
{deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '} {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
@ -106,14 +120,19 @@ const DeploymentDetailsCard = ({
</OverflownText> </OverflownText>
</span> </span>
</div> </div>
{renderDeploymentStatus('flex md:hidden h-fit')}
</div>
{/* DEPLOYMENT INFOs */} {/* DEPLOYMENT INFOs */}
<div className="ml-auto max-w-[312px] w-[30%] gap-1 2xl:gap-5 flex items-center justify-between text-elements-low-em text-sm"> <div className="md:ml-auto w-full md:max-w-[312px] md:w-[30%] gap-1 2xl:gap-5 flex items-center justify-between text-elements-low-em text-sm">
<div className="flex w-[70%] items-center gap-0.5 2xl:gap-2 flex-1"> <div className="flex md:w-[70%] xl:items-center gap-2 flex-1 xl:flex-row md:flex-col">
<div className="flex gap-2 items-center">
<ClockOutlineIcon className="h-4 w-4" /> <ClockOutlineIcon className="h-4 w-4" />
<OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}> <OverflownText content={relativeTimeMs(deployment.createdAt) ?? ''}>
{relativeTimeMs(deployment.createdAt)} {relativeTimeMs(deployment.createdAt)}
</OverflownText> </OverflownText>
</div>
<div className="flex gap-2 items-center">
<div> <div>
<Avatar <Avatar
type="orange" type="orange"
@ -123,16 +142,15 @@ const DeploymentDetailsCard = ({
// imageSrc={deployment.createdBy.avatarUrl} // imageSrc={deployment.createdBy.avatarUrl}
></Avatar> ></Avatar>
</div> </div>
<OverflownText <OverflownText
// className="min-w-[200px]"
content={formatAddress(deployment.createdBy?.name ?? '')} content={formatAddress(deployment.createdBy?.name ?? '')}
> >
{formatAddress(deployment.createdBy.name ?? '')} {formatAddress(deployment.createdBy.name ?? '')}
</OverflownText> </OverflownText>
</div> </div>
</div>
<DeploymentMenu <DeploymentMenu
className="ml-auto" className="ml-auto md:static absolute top-4 right-0"
deployment={deployment} deployment={deployment}
currentDeployment={currentDeployment} currentDeployment={currentDeployment}
onUpdate={onUpdate} onUpdate={onUpdate}

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);
}} }}
>
<div className="flex flex-col gap-2">
<Typography variant="small" placeholder={''}>
Upon confirmation, this deployment will replace your current
deployment
</Typography>
<DeploymentDialogBodyCard
deployment={currentDeployment} deployment={currentDeployment}
chip={{ newDeployment={deployment}
value: 'Live Deployment', domains={currentDeployment.domain ? [currentDeployment.domain] : []}
color: 'green',
}}
/> />
<DeploymentDialogBodyCard
deployment={deployment}
chip={{
value: 'New Deployment',
color: 'orange',
}}
/>
<Typography variant="small" placeholder={''}>
These domains will point to your new deployment:
</Typography>
<Typography variant="small" color="blue" placeholder={''}>
^ {currentDeployment.domain?.name}
</Typography>
</div>
</ConfirmDialog>
)} )}
<AssignDomainDialog <AssignDomainDialog
open={assignDomainDialog} open={assignDomainDialog}

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

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

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

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;
@ -23,20 +27,16 @@ const DisplayEnvironmentVariables = ({
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