forked from cerc-io/snowballtools-base
Sign in with google, using snowball's sdk (#188)
This commit is contained in:
commit
4aac93b504
@ -13,6 +13,7 @@
|
|||||||
"@types/semver": "^7.5.8",
|
"@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",
|
||||||
@ -45,6 +46,7 @@
|
|||||||
"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",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.2.2",
|
||||||
|
@ -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
|
||||||
|
@ -1,41 +1,55 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { SiweMessage, generateNonce } from 'siwe';
|
import { SiweMessage } from 'siwe';
|
||||||
|
import { Service } from '../service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/nonce', async (_, res) => {
|
|
||||||
res.send(generateNonce());
|
|
||||||
});
|
|
||||||
|
|
||||||
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.loadOrCreateUser(data.address);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.address = data.address;
|
||||||
|
|
||||||
res.send({ success });
|
res.send({ success });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/session', (req, res) => {
|
router.get('/session', (req, res) => {
|
||||||
if (req.session.address && req.session.chainId) {
|
if (req.session.address) {
|
||||||
res.send({ address: req.session.address, chainId: req.session.chainId });
|
res.send({
|
||||||
|
userId: req.session.userId,
|
||||||
|
address: req.session.address,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(401).send({ error: 'Unauthorized: No active session' });
|
res.status(401).send({ error: 'Unauthorized: No active session' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
req.session.destroy((err) => {
|
// This is how you clear cookie-session
|
||||||
if (err) {
|
(req as any).session = null;
|
||||||
return res.send({ success: false });
|
|
||||||
}
|
|
||||||
res.send({ success: true });
|
res.send({ success: true });
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@ -6,9 +6,9 @@ import { createServer } from 'http';
|
|||||||
import {
|
import {
|
||||||
ApolloServerPluginDrainHttpServer,
|
ApolloServerPluginDrainHttpServer,
|
||||||
ApolloServerPluginLandingPageLocalDefault,
|
ApolloServerPluginLandingPageLocalDefault,
|
||||||
AuthenticationError
|
AuthenticationError,
|
||||||
} from 'apollo-server-core';
|
} from 'apollo-server-core';
|
||||||
import session from 'express-session';
|
import cookieSession from 'cookie-session';
|
||||||
|
|
||||||
import { TypeSource } from '@graphql-tools/utils';
|
import { TypeSource } from '@graphql-tools/utils';
|
||||||
import { makeExecutableSchema } from '@graphql-tools/schema';
|
import { makeExecutableSchema } from '@graphql-tools/schema';
|
||||||
@ -23,8 +23,8 @@ const log = debug('snowball:server');
|
|||||||
|
|
||||||
declare module 'express-session' {
|
declare module 'express-session' {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
|
userId: string;
|
||||||
address: string;
|
address: string;
|
||||||
chainId: number;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,7 +32,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 +45,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({
|
||||||
@ -68,32 +68,18 @@ export const createAndStartServer = async (
|
|||||||
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 +87,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 +102,8 @@ export const createAndStartServer = async (
|
|||||||
path: gqlPath,
|
path: gqlPath,
|
||||||
cors: {
|
cors: {
|
||||||
origin: [appOriginUrl],
|
origin: [appOriginUrl],
|
||||||
credentials: true
|
credentials: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
@ -161,13 +161,17 @@ export class Service {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
async getUserByEthAddress (ethAddress: string): Promise<User | null> {
|
||||||
// Get user by ETH address
|
return await this.db.getUser({
|
||||||
let user = await this.db.getUser({
|
|
||||||
where: {
|
where: {
|
||||||
ethAddress
|
ethAddress
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadOrCreateUser (ethAddress: string): Promise<User> {
|
||||||
|
// Get user by ETH address
|
||||||
|
let user = await this.getUserByEthAddress(ethAddress);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const [org] = await this.db.getOrganizations({});
|
const [org] = await this.db.getOrganizations({});
|
||||||
|
@ -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));
|
||||||
|
@ -8,4 +8,6 @@ VITE_WALLET_CONNECT_ID=
|
|||||||
|
|
||||||
VITE_LIT_RELAY_API_KEY=
|
VITE_LIT_RELAY_API_KEY=
|
||||||
|
|
||||||
|
VITE_ALCHEMY_API_KEY=
|
||||||
|
|
||||||
LOCAL_SNOWBALL_SDK_DIR=
|
LOCAL_SNOWBALL_SDK_DIR=
|
||||||
|
@ -21,15 +21,18 @@
|
|||||||
"@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.1.1",
|
||||||
|
"@snowballtools/auth-lit": "^0.1.1",
|
||||||
|
"@snowballtools/js-sdk": "^0.1.1",
|
||||||
|
"@snowballtools/link-lit-alchemy-light": "^0.1.1",
|
||||||
"@snowballtools/material-tailwind-react-fork": "^2.1.10",
|
"@snowballtools/material-tailwind-react-fork": "^2.1.10",
|
||||||
|
"@snowballtools/smartwallet-alchemy-light": "^0.1.1",
|
||||||
|
"@snowballtools/types": "^0.1.1",
|
||||||
|
"@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",
|
|
||||||
"@types/node": "^16.18.68",
|
|
||||||
"@types/react": "^18.2.77",
|
|
||||||
"@types/react-dom": "^18.2.17",
|
|
||||||
"@walletconnect/ethereum-provider": "^2.12.2",
|
"@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",
|
||||||
@ -57,14 +60,18 @@
|
|||||||
"siwe": "^2.1.4",
|
"siwe": "^2.1.4",
|
||||||
"tailwind-variants": "^0.2.0",
|
"tailwind-variants": "^0.2.0",
|
||||||
"usehooks-ts": "^2.15.1",
|
"usehooks-ts": "^2.15.1",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"viem": "^2.7.11",
|
"viem": "^2.7.11",
|
||||||
"wagmi": "^2.5.7",
|
"wagmi": "^2.5.7",
|
||||||
"web-vitals": "^2.1.4"
|
"web-vitals": "^2.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jest": "^27.5.2",
|
||||||
"@types/luxon": "^3.3.7",
|
"@types/luxon": "^3.3.7",
|
||||||
|
"@types/node": "^16.18.68",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.2.22",
|
"@types/react-dom": "^18.2.22",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
|
3
packages/frontend/public/dot-border-line.svg
Normal file
3
packages/frontend/public/dot-border-line.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg width="197" height="2" viewBox="0 0 197 2" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="0.5" y1="1.19141" x2="197" y2="1.19141" stroke="#94A7B8" stroke-dasharray="1 12"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 196 B |
9
packages/frontend/reload-dev.sh
Executable file
9
packages/frontend/reload-dev.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
(cd /Users/rabbit-m2/p/snowball/snowball-ts-sdk && NO_CLEAN=1 turbo build)
|
||||||
|
|
||||||
|
(cd ../.. && ./scripts/yarn-file-for-local-dev.sh)
|
||||||
|
|
||||||
|
rm -rf node_modules/.vite
|
||||||
|
|
||||||
|
yarn dev
|
@ -8,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([
|
||||||
{
|
{
|
||||||
@ -45,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;
|
||||||
|
218
packages/frontend/src/components/CloudyFlow.tsx
Normal file
218
packages/frontend/src/components/CloudyFlow.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const AppleIcon: React.FC<CustomIconProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="21"
|
||||||
|
height="21"
|
||||||
|
viewBox="0 0 21 21"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g clipPath="url(#clip0_2415_13467)">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M18.234 16.046c-.29.67-.633 1.286-1.03 1.853-.543.772-.986 1.307-1.328 1.604-.53.487-1.098.737-1.706.751-.437 0-.963-.124-1.576-.376-.615-.25-1.18-.375-1.696-.375-.542 0-1.123.124-1.745.375-.623.252-1.124.383-1.508.396-.583.025-1.164-.232-1.744-.771-.37-.323-.834-.877-1.389-1.661-.595-.838-1.084-1.809-1.468-2.916-.41-1.197-.616-2.355-.616-3.476 0-1.284.277-2.392.833-3.32a4.89 4.89 0 011.746-1.767 4.696 4.696 0 012.36-.665c.463 0 1.07.143 1.825.424.753.283 1.236.426 1.448.426.159 0 .695-.167 1.606-.501.86-.31 1.587-.438 2.182-.388 1.612.13 2.824.766 3.63 1.911-1.443.874-2.156 2.098-2.142 3.668.013 1.223.457 2.24 1.329 3.049.395.375.836.665 1.327.87-.106.31-.219.605-.338.889zM14.535 1.493c0 .958-.35 1.853-1.048 2.682-.842.985-1.861 1.554-2.966 1.464a3 3 0 01-.022-.363c0-.92.4-1.905 1.112-2.71a4.282 4.282 0 011.354-1.018c.547-.266 1.064-.413 1.55-.439.015.129.02.257.02.384z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_2415_13467">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M0 0H20V20H0z"
|
||||||
|
transform="translate(.5 .691)"
|
||||||
|
></path>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -3,11 +3,11 @@ import { CustomIcon, CustomIconProps } from './CustomIcon';
|
|||||||
export const ArrowRightCircleFilledIcon = (props: CustomIconProps) => {
|
export const ArrowRightCircleFilledIcon = (props: CustomIconProps) => {
|
||||||
return (
|
return (
|
||||||
<CustomIcon
|
<CustomIcon
|
||||||
{...props}
|
|
||||||
fill="none"
|
fill="none"
|
||||||
height="32"
|
height="32"
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
width="32"
|
width="32"
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
clipRule="evenodd"
|
clipRule="evenodd"
|
||||||
|
@ -0,0 +1,38 @@
|
|||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const GoogleIcon: React.FC<CustomIconProps> = (props) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 21 21"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M19.3 11.219c0-.65-.058-1.275-.167-1.875H10.5v3.546h4.933a4.217 4.217 0 01-1.829 2.766v2.3h2.963C18.3 16.36 19.3 14.01 19.3 11.22z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10.5 20.179c2.475 0 4.55-.82 6.066-2.22l-2.962-2.3c-.82.55-1.87.874-3.104.874-2.388 0-4.409-1.612-5.13-3.78H2.309v2.376a9.163 9.163 0 008.192 5.05z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.37 12.753a5.51 5.51 0 01-.287-1.742c0-.604.104-1.191.288-1.741V6.895H2.308a9.163 9.163 0 00-.975 4.116c0 1.48.354 2.88.975 4.117l3.063-2.375z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10.5 5.49c1.346 0 2.554.462 3.504 1.37l2.63-2.629C15.045 2.752 12.97 1.844 10.5 1.844a9.163 9.163 0 00-8.192 5.05l3.063 2.375c.72-2.167 2.741-3.78 5.129-3.78z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,80 @@
|
|||||||
|
import { CustomIcon, CustomIconProps } from './CustomIcon';
|
||||||
|
|
||||||
|
export const KeyIcon = (props: CustomIconProps) => {
|
||||||
|
return (
|
||||||
|
<CustomIcon
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="33"
|
||||||
|
height="33"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 33 33"
|
||||||
|
>
|
||||||
|
<g clipPath="url(#clip0_2415_13439)">
|
||||||
|
<g filter="url(#filter0_i_2415_13439)">
|
||||||
|
<path
|
||||||
|
fill="#0F86F5"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M1.833 16.691a8 8 0 0114.499-4.666h12.142l3.733 4.666-3.733 4.667h-4.289l-2.352-1.176-2.352 1.176h-3.15a8 8 0 01-14.498-4.667zm8 2a2 2 0 100-4 2 2 0 000 4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
stroke="#000"
|
||||||
|
strokeOpacity="0.1"
|
||||||
|
d="M15.926 12.317l.15.208h12.158l3.333 4.166-3.333 4.167h-3.93l-2.247-1.123-.224-.112-.223.112-2.247 1.123H16.075l-.15.208a7.5 7.5 0 110-8.75zM9.833 19.19a2.5 2.5 0 100-5 2.5 2.5 0 000 5z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="filter0_i_2415_13439"
|
||||||
|
width="30.374"
|
||||||
|
height="17.5"
|
||||||
|
x="1.833"
|
||||||
|
y="8.691"
|
||||||
|
colorInterpolationFilters="sRGB"
|
||||||
|
filterUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
|
||||||
|
<feBlend
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="BackgroundImageFix"
|
||||||
|
result="shape"
|
||||||
|
></feBlend>
|
||||||
|
<feColorMatrix
|
||||||
|
in="SourceAlpha"
|
||||||
|
result="hardAlpha"
|
||||||
|
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||||
|
></feColorMatrix>
|
||||||
|
<feOffset dy="2"></feOffset>
|
||||||
|
<feGaussianBlur stdDeviation="0.75"></feGaussianBlur>
|
||||||
|
<feComposite
|
||||||
|
in2="hardAlpha"
|
||||||
|
k2="-1"
|
||||||
|
k3="1"
|
||||||
|
operator="arithmetic"
|
||||||
|
></feComposite>
|
||||||
|
<feColorMatrix values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"></feColorMatrix>
|
||||||
|
<feBlend
|
||||||
|
in2="shape"
|
||||||
|
result="effect1_innerShadow_2415_13439"
|
||||||
|
></feBlend>
|
||||||
|
</filter>
|
||||||
|
<clipPath id="clip0_2415_13439">
|
||||||
|
<path
|
||||||
|
fill="#fff"
|
||||||
|
d="M0 0H32V32H0z"
|
||||||
|
transform="translate(.5 .691)"
|
||||||
|
></path>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</CustomIcon>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,19 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import { cn } from 'utils/classnames';
|
||||||
|
|
||||||
|
export interface DotBorderProps extends ComponentPropsWithoutRef<'div'> {}
|
||||||
|
|
||||||
|
export const DotBorder = ({ className, ...props }: DotBorderProps) => {
|
||||||
|
const imageSrc = '/dot-border-line.svg';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...props} className={cn(className)}>
|
||||||
|
<div
|
||||||
|
className="h-1 w-full bg-repeat-x bg-top"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${imageSrc})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './DotBorder';
|
@ -52,7 +52,8 @@ export const InlineNotification = ({
|
|||||||
|
|
||||||
// Render custom icon or default icon
|
// Render custom icon or default icon
|
||||||
const renderIcon = useCallback(() => {
|
const renderIcon = useCallback(() => {
|
||||||
if (!icon) return <InfoSquareIcon className={iconClass()} />;
|
if (!icon)
|
||||||
|
return <InfoSquareIcon className={`${iconClass()} flex-shrink-0`} />;
|
||||||
return cloneIcon(icon, { className: iconClass() });
|
return cloneIcon(icon, { className: iconClass() });
|
||||||
}, [icon]);
|
}, [icon]);
|
||||||
|
|
||||||
|
@ -85,8 +85,13 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
|
|||||||
));
|
));
|
||||||
}, [orgSlug]);
|
}, [orgSlug]);
|
||||||
|
|
||||||
const handleLogOut = useCallback(() => {
|
const handleLogOut = useCallback(async () => {
|
||||||
|
await fetch(`${import.meta.env.VITE_SERVER_URL}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
disconnect();
|
disconnect();
|
||||||
|
localStorage.clear();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}, [disconnect, navigate]);
|
}, [disconnect, navigate]);
|
||||||
|
|
||||||
|
@ -1,127 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { SiweMessage } from 'siwe';
|
|
||||||
import { WagmiProvider } from 'wagmi';
|
|
||||||
import { arbitrum, mainnet } from 'wagmi/chains';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
import { createWeb3Modal } from '@web3modal/wagmi/react';
|
|
||||||
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
|
|
||||||
import { createSIWEConfig } from '@web3modal/siwe';
|
|
||||||
import type {
|
|
||||||
SIWECreateMessageArgs,
|
|
||||||
SIWEVerifyMessageArgs,
|
|
||||||
} from '@web3modal/siwe';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
|
||||||
|
|
||||||
const axiosInstance = axios.create({
|
|
||||||
baseURL: import.meta.env.VITE_SERVER_URL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
},
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const metadata = {
|
|
||||||
name: 'Snowball Tools',
|
|
||||||
description: 'Snowball Tools Dashboard',
|
|
||||||
url: window.location.origin,
|
|
||||||
icons: [
|
|
||||||
'https://raw.githubusercontent.com/snowball-tools/mediakit/main/assets/logo.svg',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const chains = [mainnet, arbitrum] as const;
|
|
||||||
const config = defaultWagmiConfig({
|
|
||||||
chains,
|
|
||||||
projectId: import.meta.env.VITE_WALLET_CONNECT_ID,
|
|
||||||
metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
const siweConfig = createSIWEConfig({
|
|
||||||
createMessage: ({ nonce, address, chainId }: SIWECreateMessageArgs) =>
|
|
||||||
new SiweMessage({
|
|
||||||
version: '1',
|
|
||||||
domain: window.location.host,
|
|
||||||
uri: window.location.origin,
|
|
||||||
address,
|
|
||||||
chainId,
|
|
||||||
nonce,
|
|
||||||
// Human-readable ASCII assertion that the user will sign, and it must not contain `\n`.
|
|
||||||
statement: 'Sign in With Ethereum.',
|
|
||||||
}).prepareMessage(),
|
|
||||||
getNonce: async () => {
|
|
||||||
const nonce = (await axiosInstance.get('/auth/nonce')).data;
|
|
||||||
if (!nonce) {
|
|
||||||
throw new Error('Failed to get nonce!');
|
|
||||||
}
|
|
||||||
|
|
||||||
return nonce;
|
|
||||||
},
|
|
||||||
getSession: async () => {
|
|
||||||
try {
|
|
||||||
const session = (await axiosInstance.get('/auth/session')).data;
|
|
||||||
const { address, chainId } = session;
|
|
||||||
|
|
||||||
return { address, chainId };
|
|
||||||
} catch (err) {
|
|
||||||
if (window.location.pathname !== '/login') {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to get session!');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
|
|
||||||
try {
|
|
||||||
const { success } = (
|
|
||||||
await axiosInstance.post('/auth/validate', {
|
|
||||||
message,
|
|
||||||
signature,
|
|
||||||
})
|
|
||||||
).data;
|
|
||||||
|
|
||||||
return success;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signOut: async () => {
|
|
||||||
try {
|
|
||||||
const { success } = (await axiosInstance.post('/auth/logout')).data;
|
|
||||||
return success;
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSignOut: () => {
|
|
||||||
window.location.href = '/login';
|
|
||||||
},
|
|
||||||
onSignIn: () => {
|
|
||||||
window.location.href = '/';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!import.meta.env.VITE_WALLET_CONNECT_ID) {
|
|
||||||
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
|
|
||||||
}
|
|
||||||
|
|
||||||
createWeb3Modal({
|
|
||||||
siweConfig,
|
|
||||||
wagmiConfig: config,
|
|
||||||
projectId: import.meta.env.VITE_WALLET_CONNECT_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Web3ModalProvider({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<WagmiProvider config={config}>
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
</WagmiProvider>
|
|
||||||
);
|
|
||||||
}
|
|
36
packages/frontend/src/context/Web3Provider.tsx
Normal file
36
packages/frontend/src/context/Web3Provider.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { WagmiProvider } from 'wagmi';
|
||||||
|
import { arbitrum, mainnet } from 'wagmi/chains';
|
||||||
|
|
||||||
|
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
name: 'Snowball Tools',
|
||||||
|
description: 'Snowball Tools Dashboard',
|
||||||
|
url: window.location.origin,
|
||||||
|
icons: [
|
||||||
|
'https://raw.githubusercontent.com/snowball-tools/mediakit/main/assets/logo.svg',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chains = [mainnet, arbitrum] as const;
|
||||||
|
const config = defaultWagmiConfig({
|
||||||
|
chains,
|
||||||
|
projectId: import.meta.env.VITE_WALLET_CONNECT_ID,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!import.meta.env.VITE_WALLET_CONNECT_ID) {
|
||||||
|
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Web3Provider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={config}>
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,9 @@
|
|||||||
|
@import 'layouts/global.css';
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter Display';
|
font-family: 'Inter Display';
|
||||||
|
@ -13,7 +13,6 @@ import reportWebVitals from './reportWebVitals';
|
|||||||
import { GQLClientProvider } from './context/GQLClientContext';
|
import { GQLClientProvider } from './context/GQLClientContext';
|
||||||
import { SERVER_GQL_PATH } from './constants';
|
import { SERVER_GQL_PATH } from './constants';
|
||||||
import { Toaster } from 'components/shared/Toast';
|
import { Toaster } from 'components/shared/Toast';
|
||||||
import Web3ModalProvider from './context/Web3ModalProvider';
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement,
|
document.getElementById('root') as HTMLElement,
|
||||||
);
|
);
|
||||||
@ -30,9 +29,7 @@ root.render(
|
|||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<GQLClientProvider client={gqlClient}>
|
<GQLClientProvider client={gqlClient}>
|
||||||
<Web3ModalProvider>
|
|
||||||
<App />
|
<App />
|
||||||
</Web3ModalProvider>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</GQLClientProvider>
|
</GQLClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
6
packages/frontend/src/layouts/global.css
Normal file
6
packages/frontend/src/layouts/global.css
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.flex-center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
28
packages/frontend/src/pages/AuthPage.tsx
Normal file
28
packages/frontend/src/pages/AuthPage.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { CloudyFlow } from 'components/CloudyFlow';
|
||||||
|
import { SnowballAuth } from './auth/SnowballAuth';
|
||||||
|
|
||||||
|
const AuthPage = () => {
|
||||||
|
return (
|
||||||
|
<CloudyFlow className="flex flex-col min-h-screen">
|
||||||
|
<div className="py-8 relative z-10">
|
||||||
|
<div className="flex justify-center items-center gap-2.5">
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="snowball logo"
|
||||||
|
className="h-10 rounded-xl"
|
||||||
|
/>
|
||||||
|
<div className="text-sky-950 text-2xl font-semibold font-display leading-loose">
|
||||||
|
Snowball
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pb-12 relative z-10 flex-1 flex-center">
|
||||||
|
<div className="max-w-[520px] w-full bg-white rounded-xl shadow">
|
||||||
|
<SnowballAuth />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CloudyFlow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthPage;
|
@ -1,18 +0,0 @@
|
|||||||
import './Snow.css';
|
|
||||||
|
|
||||||
const Login = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-screen bg-snowball-900 snow">
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<img
|
|
||||||
src="/logo.svg"
|
|
||||||
alt="snowball logo"
|
|
||||||
className="w-32 h-32 rounded-full mb-4"
|
|
||||||
/>
|
|
||||||
<w3m-button />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Login;
|
|
@ -1,25 +0,0 @@
|
|||||||
.snow {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 50 50' style='enable-background:new 0 0 50 50%3B' xml:space='preserve'%3E%3Cstyle type='text/css'%3E.st1%7Bopacity:0.3%3Bfill:%23FFFFFF%3B%7D.st3%7Bopacity:0.1%3Bfill:%23FFFFFF%3B%7D%3C/style%3E%3Ccircle class='st1' cx='5' cy='8' r='1'/%3E%3Ccircle class='st1' cx='38' cy='3' r='1'/%3E%3Ccircle class='st1' cx='12' cy='4' r='1'/%3E%3Ccircle class='st1' cx='16' cy='16' r='1'/%3E%3Ccircle class='st1' cx='47' cy='46' r='1'/%3E%3Ccircle class='st1' cx='32' cy='10' r='1'/%3E%3Ccircle class='st1' cx='3' cy='46' r='1'/%3E%3Ccircle class='st1' cx='45' cy='13' r='1'/%3E%3Ccircle class='st1' cx='10' cy='28' r='1'/%3E%3Ccircle class='st1' cx='22' cy='35' r='1'/%3E%3Ccircle class='st1' cx='3' cy='21' r='1'/%3E%3Ccircle class='st1' cx='26' cy='20' r='1'/%3E%3Ccircle class='st1' cx='30' cy='45' r='1'/%3E%3Ccircle class='st1' cx='15' cy='45' r='1'/%3E%3Ccircle class='st1' cx='34' cy='36' r='1'/%3E%3Ccircle class='st1' cx='41' cy='32' r='1'/%3E%3C/svg%3E");
|
|
||||||
background-position: 0px 0px;
|
|
||||||
animation: animatedBackground 230s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.snow div {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 50 50' style='enable-background:new 0 0 50 50%3B' xml:space='preserve'%3E%3Cstyle type='text/css'%3E.st1%7Bopacity:0.7%3Bfill:%23FFFFFF%3B%7D.st3%7Bopacity:0.1%3Bfill:%23FFFFFF%3B%7D%3C/style%3E%3Ccircle class='st3' cx='4' cy='14' r='1'/%3E%3Ccircle class='st3' cx='43' cy='3' r='1'/%3E%3Ccircle class='st3' cx='31' cy='30' r='2'/%3E%3Ccircle class='st3' cx='19' cy='23' r='1'/%3E%3Ccircle class='st3' cx='37' cy='22' r='1'/%3E%3Ccircle class='st3' cx='43' cy='16' r='1'/%3E%3Ccircle class='st3' cx='8' cy='45' r='1'/%3E%3Ccircle class='st3' cx='29' cy='39' r='1'/%3E%3Ccircle class='st3' cx='13' cy='37' r='1'/%3E%3Ccircle class='st3' cx='47' cy='32' r='1'/%3E%3Ccircle class='st3' cx='15' cy='4' r='2'/%3E%3Ccircle class='st3' cx='9' cy='27' r='1'/%3E%3Ccircle class='st3' cx='30' cy='9' r='1'/%3E%3Ccircle class='st3' cx='25' cy='15' r='1'/%3E%3Ccircle class='st3' cx='21' cy='45' r='2'/%3E%3Ccircle class='st3' cx='42' cy='45' r='1'/%3E%3C/svg%3E");
|
|
||||||
background-position: 0px 0px;
|
|
||||||
animation: animatedBackground 260s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes animatedBackground {
|
|
||||||
0% {
|
|
||||||
background-position: 0 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 0px 11600px;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
// import { useSnowball } from 'utils/use-snowball';
|
|
||||||
|
|
||||||
// export const SnowballLogin = () => {
|
|
||||||
// const snowball = useSnowball();
|
|
||||||
// console.log(snowball);
|
|
||||||
// return (
|
|
||||||
// <div className="flex items-center justify-center h-screen bg-snowball-900 snow">
|
|
||||||
// <div className="flex flex-col items-center justify-center">
|
|
||||||
// <img
|
|
||||||
// src="/logo.svg"
|
|
||||||
// alt="snowball logo"
|
|
||||||
// className="w-32 h-32 rounded-full mb-4"
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// };
|
|
83
packages/frontend/src/pages/auth/CreatePasskey.tsx
Normal file
83
packages/frontend/src/pages/auth/CreatePasskey.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import { LoaderIcon } from 'components/shared/CustomIcon';
|
||||||
|
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
|
||||||
|
import { InlineNotification } from 'components/shared/InlineNotification';
|
||||||
|
import { Input } from 'components/shared/Input';
|
||||||
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { IconRight } from 'react-day-picker';
|
||||||
|
import { useSnowball } from 'utils/use-snowball';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatePasskey = ({}: Props) => {
|
||||||
|
const snowball = useSnowball();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
const auth = snowball.auth.passkey;
|
||||||
|
const loading = !!auth.state.loading;
|
||||||
|
|
||||||
|
async function createPasskey() {
|
||||||
|
await auth.register(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||||
|
<div className="w-16 h-16 p-2 bg-sky-100 rounded-[800px] justify-center items-center gap-2 inline-flex">
|
||||||
|
<KeyIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-loose">
|
||||||
|
Create a passkey
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Passkeys allow you to sign in securely without using passwords.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WavyBorder className="self-stretch" variant="stroke" />
|
||||||
|
<div className="p-6 flex-col justify-center items-center gap-8 inline-flex">
|
||||||
|
<div className="self-stretch h-36 flex-col justify-center items-center gap-2 flex">
|
||||||
|
<div className="self-stretch h-[72px] flex-col justify-start items-start gap-2 flex">
|
||||||
|
<div className="self-stretch h-5 px-1 flex-col justify-start items-start gap-1 flex">
|
||||||
|
<div className="self-stretch text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Give it a name
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onInput={(e: any) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{auth.state.error ? (
|
||||||
|
<InlineNotification
|
||||||
|
title={auth.state.error.message}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InlineNotification
|
||||||
|
title={`Once you press the "Create passkeys" button, you'll receive a prompt to create the passkey.`}
|
||||||
|
variant="info"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
rightIcon={
|
||||||
|
loading ? <LoaderIcon className="animate-spin" /> : <IconRight />
|
||||||
|
}
|
||||||
|
className="self-stretch"
|
||||||
|
disabled={!name || loading}
|
||||||
|
onClick={createPasskey}
|
||||||
|
>
|
||||||
|
Create Passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
41
packages/frontend/src/pages/auth/Done.tsx
Normal file
41
packages/frontend/src/pages/auth/Done.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import {
|
||||||
|
ArrowRightCircleFilledIcon,
|
||||||
|
CheckRoundFilledIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
continueTo: string;
|
||||||
|
};
|
||||||
|
export const Done = ({ continueTo }: Props) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||||
|
<div className="w-16 h-16 p-2 bg-sky-100 rounded-[800px] justify-center items-center gap-2 inline-flex">
|
||||||
|
<CheckRoundFilledIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-loose">
|
||||||
|
You're in!
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
It's time to get your next project rolling!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WavyBorder className="self-stretch" variant="stroke" />
|
||||||
|
<div className="p-6 self-stretch flex flex-col gap-8">
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
rightIcon={<ArrowRightCircleFilledIcon />}
|
||||||
|
className="self-stretch"
|
||||||
|
href={continueTo}
|
||||||
|
variant={'primary'}
|
||||||
|
>
|
||||||
|
Enter Snowball
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
262
packages/frontend/src/pages/auth/Login.tsx
Normal file
262
packages/frontend/src/pages/auth/Login.tsx
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import {
|
||||||
|
ArrowRightCircleFilledIcon,
|
||||||
|
GithubIcon,
|
||||||
|
LinkIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
QuestionMarkRoundFilledIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
|
||||||
|
import { DotBorder } from 'components/shared/DotBorder';
|
||||||
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { CreatePasskey } from './CreatePasskey';
|
||||||
|
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
|
||||||
|
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
|
||||||
|
import { useToast } from 'components/shared/Toast';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
|
||||||
|
import { signInWithEthereum } from 'utils/siwe';
|
||||||
|
import { useSnowball } from 'utils/use-snowball';
|
||||||
|
|
||||||
|
type Provider = 'google' | 'github' | 'apple' | 'email' | 'passkey';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Login = ({ onDone }: Props) => {
|
||||||
|
const snowball = useSnowball();
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [provider, setProvider] = useState<Provider | false>(false);
|
||||||
|
|
||||||
|
// const loading = snowball.auth.state.loading && provider;
|
||||||
|
const loading = provider;
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
if (provider === 'email') {
|
||||||
|
return <CreatePasskey onDone={onDone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSigninRedirect() {
|
||||||
|
let wallet: PKPEthersWallet | undefined;
|
||||||
|
const { google } = snowball.auth;
|
||||||
|
if (google.canHandleOAuthRedirectBack()) {
|
||||||
|
setProvider('google');
|
||||||
|
console.log('Handling google redirect back');
|
||||||
|
try {
|
||||||
|
await google.handleOAuthRedirectBack();
|
||||||
|
wallet = await google.getEthersWallet();
|
||||||
|
const result = await signInWithEthereum(1, 'login', wallet);
|
||||||
|
if (result.error) {
|
||||||
|
setError(result.error);
|
||||||
|
setProvider(false);
|
||||||
|
wallet = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
console.log(err.message, err.name, err.details);
|
||||||
|
setProvider(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if (apple.canHandleOAuthRedirectBack()) {
|
||||||
|
// setProvider('apple');
|
||||||
|
// console.log('Handling apple redirect back');
|
||||||
|
// try {
|
||||||
|
// await apple.handleOAuthRedirectBack();
|
||||||
|
// wallet = await apple.getEthersWallet();
|
||||||
|
// const result = await signInWithEthereum(1, 'login', wallet);
|
||||||
|
// if (result.error) {
|
||||||
|
// setError(result.error);
|
||||||
|
// setProvider(false);
|
||||||
|
// wallet = undefined;
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// } catch (err: any) {
|
||||||
|
// setError(err.message);
|
||||||
|
// console.log(err.message, err.name, err.details);
|
||||||
|
// setProvider(false);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (wallet) {
|
||||||
|
window.location.pathname = '/';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleSigninRedirect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||||
|
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
|
||||||
|
Sign in to Snowball
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WavyBorder className="self-stretch" variant="stroke" />
|
||||||
|
|
||||||
|
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
|
||||||
|
<div className="self-stretch p-5 bg-slate-50 rounded-xl shadow flex-col justify-center items-center gap-6 flex">
|
||||||
|
<div className="self-stretch flex-col justify-center items-center gap-4 flex">
|
||||||
|
<KeyIcon />
|
||||||
|
<div className="self-stretch flex-col justify-center items-center gap-2 flex">
|
||||||
|
<div className="self-stretch text-center text-sky-950 text-lg font-medium font-display leading-normal">
|
||||||
|
Got a Passkey?
|
||||||
|
</div>
|
||||||
|
<div className="self-stretch text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Use it to sign in securely without using a password.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="self-stretch justify-center items-stretch xxs:items-center gap-3 flex flex-col xxs:flex-row">
|
||||||
|
<Button
|
||||||
|
as="a"
|
||||||
|
leftIcon={<QuestionMarkRoundFilledIcon />}
|
||||||
|
variant={'tertiary'}
|
||||||
|
target="_blank"
|
||||||
|
href="https://safety.google/authentication/passkey/"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'passkey' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowRightCircleFilledIcon height="16" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!!loading}
|
||||||
|
onClick={async () => {
|
||||||
|
setProvider('passkey');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign In with Passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-5 justify-center items-center gap-2 inline-flex">
|
||||||
|
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Lost your passkey?
|
||||||
|
</div>
|
||||||
|
<div className="justify-center items-center gap-1.5 flex">
|
||||||
|
<button className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight">
|
||||||
|
Recover account
|
||||||
|
</button>
|
||||||
|
<LinkIcon />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-stretch justify-start items-center gap-8 inline-flex">
|
||||||
|
<DotBorder className="flex-1" />
|
||||||
|
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
|
||||||
|
OR
|
||||||
|
</div>
|
||||||
|
<DotBorder className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-stretch flex-col justify-center items-center gap-3 flex">
|
||||||
|
<Button
|
||||||
|
leftIcon={<GoogleIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'google' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setProvider('google');
|
||||||
|
snowball.auth.google.startOAuthRedirect();
|
||||||
|
}}
|
||||||
|
className="flex-1 self-stretch"
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<GithubIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'github' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setProvider('github');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
setProvider(false);
|
||||||
|
toast({
|
||||||
|
id: 'coming-soon',
|
||||||
|
title: 'Sign-in with GitHub is coming soon!',
|
||||||
|
variant: 'info',
|
||||||
|
onDismiss() {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 self-stretch"
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<AppleIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'apple' ? (
|
||||||
|
<LoaderIcon className="animate-spin text-white" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setProvider('apple');
|
||||||
|
// snowball.auth.apple.startOAuthRedirect();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
setProvider(false);
|
||||||
|
toast({
|
||||||
|
id: 'coming-soon',
|
||||||
|
title: 'Sign-in with Apple is coming soon!',
|
||||||
|
variant: 'info',
|
||||||
|
onDismiss() {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
|
||||||
|
loading && loading === 'apple' ? 'disabled:bg-black' : ''
|
||||||
|
}`}
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with Apple
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{error && (
|
||||||
|
<div className="justify-center items-center gap-2 inline-flex">
|
||||||
|
<div className="text-red-500 text-sm">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-5 justify-center items-center gap-2 inline-flex">
|
||||||
|
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Don't have an account?
|
||||||
|
</div>
|
||||||
|
<div className="justify-center items-center gap-1.5 flex">
|
||||||
|
<Link
|
||||||
|
to="/signup"
|
||||||
|
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
|
||||||
|
>
|
||||||
|
Sign up now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
235
packages/frontend/src/pages/auth/SignUp.tsx
Normal file
235
packages/frontend/src/pages/auth/SignUp.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { Button } from 'components/shared/Button';
|
||||||
|
import {
|
||||||
|
ArrowRightCircleFilledIcon,
|
||||||
|
GithubIcon,
|
||||||
|
LoaderIcon,
|
||||||
|
} from 'components/shared/CustomIcon';
|
||||||
|
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
|
||||||
|
import { DotBorder } from 'components/shared/DotBorder';
|
||||||
|
import { WavyBorder } from 'components/shared/WavyBorder';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSnowball } from 'utils/use-snowball';
|
||||||
|
import { CreatePasskey } from './CreatePasskey';
|
||||||
|
import { Input } from 'components/shared/Input';
|
||||||
|
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useToast } from 'components/shared/Toast';
|
||||||
|
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
|
||||||
|
import { signInWithEthereum } from 'utils/siwe';
|
||||||
|
|
||||||
|
type Provider = 'google' | 'github' | 'apple' | 'email';
|
||||||
|
|
||||||
|
type Err = { type: 'email' | 'provider'; message: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SignUp = ({ onDone }: Props) => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [error, setError] = useState<Err | null>();
|
||||||
|
const [provider, setProvider] = useState<Provider | false>(false);
|
||||||
|
|
||||||
|
const { toast } = useToast();
|
||||||
|
const snowball = useSnowball();
|
||||||
|
|
||||||
|
async function handleSignupRedirect() {
|
||||||
|
let wallet: PKPEthersWallet | undefined;
|
||||||
|
const { google } = snowball.auth;
|
||||||
|
if (google.canHandleOAuthRedirectBack()) {
|
||||||
|
setProvider('google');
|
||||||
|
try {
|
||||||
|
await google.handleOAuthRedirectBack();
|
||||||
|
wallet = await google.getEthersWallet();
|
||||||
|
const result = await signInWithEthereum(1, 'signup', wallet);
|
||||||
|
if (result.error) {
|
||||||
|
setError({ type: 'provider', message: result.error });
|
||||||
|
setProvider(false);
|
||||||
|
wallet = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError({ type: 'provider', message: err.message });
|
||||||
|
setProvider(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// if (apple.canHandleOAuthRedirectBack()) {
|
||||||
|
// setProvider('apple');
|
||||||
|
// try {
|
||||||
|
// await apple.handleOAuthRedirectBack();
|
||||||
|
// wallet = await apple.getEthersWallet();
|
||||||
|
// const result = await signInWithEthereum(1, 'signup', wallet);
|
||||||
|
// if (result.error) {
|
||||||
|
// setError({ type: 'provider', message: result.error });
|
||||||
|
// setProvider(false);
|
||||||
|
// wallet = undefined;
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// } catch (err: any) {
|
||||||
|
// setError({ type: 'provider', message: err.message });
|
||||||
|
// setProvider(false);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (wallet) {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleSignupRedirect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loading = provider;
|
||||||
|
const emailValid = /.@./.test(email);
|
||||||
|
|
||||||
|
if (provider === 'email') {
|
||||||
|
return <CreatePasskey onDone={onDone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
|
||||||
|
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
|
||||||
|
Sign up to Snowball
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<WavyBorder className="self-stretch" variant="stroke" />
|
||||||
|
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
|
||||||
|
<div className="self-stretch flex-col justify-center items-center gap-3 flex">
|
||||||
|
<Button
|
||||||
|
leftIcon={loading && loading === 'google' ? null : <GoogleIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'google' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setProvider('google');
|
||||||
|
snowball.auth.google.startOAuthRedirect();
|
||||||
|
}}
|
||||||
|
className="flex-1 self-stretch"
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<GithubIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'github' ? (
|
||||||
|
<LoaderIcon className="animate-spin" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setProvider('github');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
setProvider(false);
|
||||||
|
toast({
|
||||||
|
id: 'coming-soon',
|
||||||
|
title: 'Sign-in with GitHub is coming soon!',
|
||||||
|
variant: 'info',
|
||||||
|
onDismiss() {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex-1 self-stretch"
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<AppleIcon />}
|
||||||
|
rightIcon={
|
||||||
|
loading && loading === 'apple' ? (
|
||||||
|
<LoaderIcon className="animate-spin text-white" />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setProvider('apple');
|
||||||
|
// snowball.auth.apple.startOAuthRedirect();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
setProvider(false);
|
||||||
|
toast({
|
||||||
|
id: 'coming-soon',
|
||||||
|
title: 'Sign-in with Apple is coming soon!',
|
||||||
|
variant: 'info',
|
||||||
|
onDismiss() {},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
|
||||||
|
loading && loading === 'apple' ? 'disabled:bg-black' : ''
|
||||||
|
}`}
|
||||||
|
variant={'tertiary'}
|
||||||
|
disabled={!!loading}
|
||||||
|
>
|
||||||
|
Continue with Apple
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && error.type === 'provider' && (
|
||||||
|
<div className="-mt-3 justify-center items-center inline-flex">
|
||||||
|
<div className="text-red-500 text-sm">Error: {error.message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="self-stretch justify-start items-center gap-8 inline-flex">
|
||||||
|
<DotBorder className="flex-1" />
|
||||||
|
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
|
||||||
|
OR
|
||||||
|
</div>
|
||||||
|
<DotBorder className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="self-stretch flex-col gap-8 flex">
|
||||||
|
<div className="flex-col justify-start items-start gap-2 inline-flex">
|
||||||
|
<div className="text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Email
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={!!loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
rightIcon={<ArrowRightCircleFilledIcon height="16" />}
|
||||||
|
onClick={() => {
|
||||||
|
setProvider('email');
|
||||||
|
}}
|
||||||
|
variant={'secondary'}
|
||||||
|
disabled={!email || !emailValid || !!loading}
|
||||||
|
>
|
||||||
|
Continue with Email
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{error && error.type === 'email' && (
|
||||||
|
<div className="justify-center items-center gap-2 inline-flex">
|
||||||
|
<div className="text-red-500 text-sm">
|
||||||
|
Error: {error.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="justify-center items-center gap-2 inline-flex">
|
||||||
|
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
|
||||||
|
Already an user?
|
||||||
|
</div>
|
||||||
|
<div className="justify-center items-center gap-1.5 flex">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
|
||||||
|
>
|
||||||
|
Sign in now
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
52
packages/frontend/src/pages/auth/SnowballAuth.tsx
Normal file
52
packages/frontend/src/pages/auth/SnowballAuth.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { snowball } from 'utils/use-snowball';
|
||||||
|
import { Login } from './Login';
|
||||||
|
import { SignUp } from './SignUp';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Done } from './Done';
|
||||||
|
|
||||||
|
type Screen = 'login' | 'signup' | 'success';
|
||||||
|
|
||||||
|
const DASHBOARD_URL = '/';
|
||||||
|
|
||||||
|
export const SnowballAuth = () => {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const [screen, setScreen] = useState<Screen>(
|
||||||
|
path === '/login' ? 'login' : 'signup',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (snowball.session) {
|
||||||
|
window.location.href = DASHBOARD_URL;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (path === '/login') {
|
||||||
|
setScreen('login');
|
||||||
|
} else if (path === '/signup') {
|
||||||
|
setScreen('signup');
|
||||||
|
}
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
if (screen === 'signup') {
|
||||||
|
return (
|
||||||
|
<SignUp
|
||||||
|
onDone={() => {
|
||||||
|
setScreen('success');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (screen === 'login') {
|
||||||
|
return (
|
||||||
|
<Login
|
||||||
|
onDone={() => {
|
||||||
|
setScreen('success');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (screen === 'success') {
|
||||||
|
return <Done continueTo={DASHBOARD_URL} />;
|
||||||
|
}
|
||||||
|
};
|
46
packages/frontend/src/utils/siwe.ts
Normal file
46
packages/frontend/src/utils/siwe.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { SiweMessage } from 'siwe';
|
||||||
|
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
const domain = window.location.host;
|
||||||
|
const origin = window.location.origin;
|
||||||
|
|
||||||
|
export async function signInWithEthereum(
|
||||||
|
chainId: number,
|
||||||
|
action: 'signup' | 'login',
|
||||||
|
wallet: PKPEthersWallet,
|
||||||
|
) {
|
||||||
|
const message = await createSiweMessage(
|
||||||
|
chainId,
|
||||||
|
await wallet.getAddress(),
|
||||||
|
'Sign in with Ethereum to the app.',
|
||||||
|
);
|
||||||
|
const signature = await wallet.signMessage(message);
|
||||||
|
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_SERVER_URL}/auth/validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ action, message, signature }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
return (await res.json()) as { success: boolean; error?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSiweMessage(
|
||||||
|
chainId: number,
|
||||||
|
address: string,
|
||||||
|
statement: string,
|
||||||
|
) {
|
||||||
|
const message = new SiweMessage({
|
||||||
|
domain,
|
||||||
|
address,
|
||||||
|
statement,
|
||||||
|
uri: origin,
|
||||||
|
version: '1',
|
||||||
|
chainId,
|
||||||
|
nonce: uuid().replace(/[^a-z0-9]/g, ''),
|
||||||
|
});
|
||||||
|
return message.prepareMessage();
|
||||||
|
}
|
@ -1,19 +0,0 @@
|
|||||||
// import { LitGoogleAuth } from '@snowballtools/auth-lit';
|
|
||||||
// import { Snowball, SnowballChain } from '@snowballtools/js-sdk';
|
|
||||||
// // import { LinkLitAlchemyLight } from '@snowballtools/link-lit-alchemy-light';
|
|
||||||
|
|
||||||
// export const DOMAIN = import.meta.env.VITE_DOMAIN || 'localhost';
|
|
||||||
// export const ORIGIN =
|
|
||||||
// import.meta.env.VITE_VERCEL_ENV === 'production'
|
|
||||||
// ? `https://${DOMAIN}`
|
|
||||||
// : `http://${DOMAIN}:3000`;
|
|
||||||
|
|
||||||
// // prettier-ignore
|
|
||||||
|
|
||||||
// export const snowball = Snowball.withAuth(
|
|
||||||
// LitGoogleAuth.configure({
|
|
||||||
// litReplayApiKey: import.meta.env.VITE_LIT_RELAY_API_KEY!,
|
|
||||||
// }),
|
|
||||||
// ).create({
|
|
||||||
// initialChain: SnowballChain.sepolia,
|
|
||||||
// });
|
|
@ -1,13 +1,32 @@
|
|||||||
// import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
// import { snowball } from './snowball';
|
import { Snowball, SnowballChain } from '@snowballtools/js-sdk';
|
||||||
|
import {
|
||||||
|
// LitAppleAuth,
|
||||||
|
LitGoogleAuth,
|
||||||
|
LitPasskeyAuth,
|
||||||
|
} from '@snowballtools/auth-lit';
|
||||||
|
|
||||||
// export function useSnowball() {
|
export const snowball = Snowball.withAuth({
|
||||||
// const [state, setState] = useState(100);
|
google: LitGoogleAuth.configure({
|
||||||
|
litRelayApiKey: import.meta.env.VITE_LIT_RELAY_API_KEY!,
|
||||||
|
}),
|
||||||
|
// apple: LitAppleAuth.configure({
|
||||||
|
// litRelayApiKey: import.meta.env.VITE_LIT_RELAY_API_KEY!,
|
||||||
|
// }),
|
||||||
|
passkey: LitPasskeyAuth.configure({
|
||||||
|
litRelayApiKey: import.meta.env.VITE_LIT_RELAY_API_KEY!,
|
||||||
|
}),
|
||||||
|
}).create({
|
||||||
|
initialChain: SnowballChain.sepolia,
|
||||||
|
});
|
||||||
|
|
||||||
// useEffect(() => {
|
export function useSnowball() {
|
||||||
// // Subscribe and directly return the unsubscribe function
|
const [state, setState] = useState(100);
|
||||||
// return snowball.subscribe(() => setState(state + 1));
|
|
||||||
// }, [snowball]);
|
|
||||||
|
|
||||||
// return snowball;
|
useEffect(() => {
|
||||||
// }
|
// Subscribe and directly return the unsubscribe function
|
||||||
|
return snowball.subscribe(() => setState(state + 1));
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return snowball;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import withMT from '@snowballtools/material-tailwind-react-fork/utils/withMT';
|
import withMT from '@snowballtools/material-tailwind-react-fork/utils/withMT';
|
||||||
|
import colors from 'tailwindcss/colors'
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default withMT({
|
export default withMT({
|
||||||
@ -9,6 +10,10 @@ export default withMT({
|
|||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
screens: {
|
||||||
|
xxs: '400px',
|
||||||
|
xs: '480px',
|
||||||
|
},
|
||||||
zIndex: {
|
zIndex: {
|
||||||
tooltip: '52',
|
tooltip: '52',
|
||||||
},
|
},
|
||||||
@ -25,6 +30,8 @@ export default withMT({
|
|||||||
'3xs': '0.5rem',
|
'3xs': '0.5rem',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
sky: colors.sky, // TODO: WHy is this necessary? We're already using tailwind v3
|
||||||
|
slate: colors.slate, // TODO: WHy is this necessary? We're already using tailwind v3
|
||||||
emerald: {
|
emerald: {
|
||||||
100: '#d1fae5',
|
100: '#d1fae5',
|
||||||
200: '#a9f1d0',
|
200: '#a9f1d0',
|
||||||
@ -173,6 +180,9 @@ export default withMT({
|
|||||||
zIndex: {
|
zIndex: {
|
||||||
toast: '9999',
|
toast: '9999',
|
||||||
},
|
},
|
||||||
|
animation: {
|
||||||
|
'spin': 'spin 3s linear infinite',
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
Loading…
Reference in New Issue
Block a user