Add transfer frontend (#93)

This commit is contained in:
Juan Enrique Alcaraz 2024-07-10 21:11:07 +02:00 committed by GitHub
parent 065747ec2a
commit 8feb20b7b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 11921 additions and 0 deletions

View file

@ -0,0 +1,5 @@
NEXT_PUBLIC_CHAIN_ID=testing
NEXT_PUBLIC_CHAIN_RPC_URL=http://0.0.0.0:26657
NEXT_PUBLIC_CHAIN_REST_URL=http://0.0.0.0:1317
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY=02ef4f843722d9badf8f5571d8f20cd1a21022fe52b9257d3a235c85dfc0ce11c0
NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS=wasm1jfgr0vgunezkhfmdy7krrupu6yjhx224nxtjptll2ylkkqhyzeshrspu9

View file

@ -0,0 +1,7 @@
{
"extends": "next/core-web-vitals",
"plugins": ["react-compiler"],
"rules": {
"react-compiler/react-compiler": "error"
}
}

36
apps/transfers/frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,11 @@
{
"plugins": ["prettier-plugin-tailwindcss"],
"quoteProps": "consistent",
"singleQuote": true,
"semi": false,
"singleAttributePerLine": true,
"tailwindFunctions": ["tw", "twJoin", "twMerge"],
"tailwindPreserveWhitespace": true,
"tabWidth": 2,
"trailingComma": "all"
}

View file

@ -0,0 +1,43 @@
# Transfer App
This is an example frontend that illustrates how to interact with a Transfer Quartz App.
This example offers:
- Deposit amounts into a balance
- Withdraw the whole deposit
- Transfer amounts between wallet addresses in a private-preserving way
- Query your encrypted balance to capture changes
- Switch between Keplr wallets
## Requirements
In order to get started, you will need:
- [Node.js](https://nodejs.org/) LTS (v20.x)
- `npm`
- A [Keplr](https://www.keplr.app/) Wallet
## Development
Install dependencies:
```bash
npm ci
```
The App requires some environment variables to fully work. Be sure to set up those accordingly to your local environment.
You should start from the template:
```bash
cp .env.example .env.local
```
Run the app:
```bash
npm run dev
```
And now everything is up & running 🎉

View file

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
reactCompiler: true,
},
}
export default nextConfig

5709
apps/transfers/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
{
"name": "transfer-fe",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate": "node ./scripts/generate.js && node ./scripts/rebuild-component-index.js",
"rebuild-component-index": "node ./scripts/rebuild-component-index.js"
},
"dependencies": {
"@cosmjs/cosmwasm-stargate": "^0.32.4",
"@cosmwasm/ts-codegen": "^1.11.1",
"eciesjs": "^0.4.7",
"lodash": "^4.17.21",
"next": "^15.0.0-rc.0",
"react": "^19.0.0-rc-f994737d14-20240522",
"react-dom": "^19.0.0-rc-f994737d14-20240522",
"tailwind-merge": "^2.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@keplr-wallet/types": "0.12.103",
"@netlify/plugin-nextjs": "^5.3.3",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.13",
"@types/lodash": "^4.17.5",
"@types/node": "^20",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
"eslint": "^8",
"eslint-plugin-react-compiler": "^0.0.0",
"postcss": "^8",
"prettier": "^3.3.2",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.4",
"tiny-invariant": "^1.3.3",
"typescript": "^5"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
}
}

View file

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1,105 @@
const fs = require('fs')
const readline = require('node:readline')
const componentsDirectory = `${__dirname}/../src/components`
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
rl.question(
`What do you want to name your new component? `,
(newComponentName) => {
newComponentName = newComponentName || 'NewComponent'
rl.question(
`What tag name do you want to use for your new component? (default: div) `,
(tagName) => {
tagName = tagName || 'div'
rl.question(
`Is this a complex component? (Y/N, default: N)`,
(isComplex) => {
isComplex = isComplex === 'Y' ? true : false
const replacementsMap = {
'NewComponent': newComponentName,
'div': tagName,
'//[^\n]+': '',
}
if (isComplex) {
const componentDirectory = `${componentsDirectory}/${newComponentName}`
// Make sure components directory exists
fs.mkdirSync(componentsDirectory, { recursive: true })
// Create the new component directory and copy the template files
fs.cpSync(
`${__dirname}/templates/NewComponent`,
componentDirectory,
{
recursive: true,
},
)
const template = fs.readFileSync(
`${__dirname}/templates/NewComponent/NewComponent.tsx`,
'utf8',
)
const mergedTemplate = Object.entries(replacementsMap).reduce(
(acc, [key, value]) => {
return acc.replace(new RegExp(`${key}`, 'isg'), value)
},
template,
)
const newComponentFile = `${componentDirectory}/NewComponent.tsx`
fs.writeFileSync(newComponentFile, mergedTemplate)
fs.writeFileSync(
`${componentDirectory}/index.ts`,
`export { ${newComponentName} } from './${newComponentName}'\n`,
)
fs.renameSync(
newComponentFile,
`${componentDirectory}/${newComponentName}.tsx`,
)
} else {
const newComponentFile = `${componentsDirectory}/${newComponentName}.tsx`
fs.cpSync(
`${__dirname}/templates/NewComponent.tsx`,
newComponentFile,
)
const template = fs.readFileSync(
`${__dirname}/templates/NewComponent.tsx`,
'utf8',
)
const mergedTemplate = Object.entries(replacementsMap).reduce(
(acc, [key, value]) => {
return acc.replace(new RegExp(`${key}`, 'isg'), value)
},
template,
)
fs.writeFileSync(newComponentFile, mergedTemplate)
}
console.log(
`Component ${newComponentName} created at ${componentsDirectory}`,
)
rl.close()
},
)
},
)
},
)

View file

@ -0,0 +1,43 @@
const fs = require('fs')
const componentsDirectory = `${__dirname}/../src/components`
const indexFile = `${componentsDirectory}/index.ts`
// Get directories and files in the components directory
const filesAndDirectories = fs.readdirSync(componentsDirectory)
const [files, directories] = filesAndDirectories.reduce(
(acc, fileOrDirectory) => {
if (
fs.statSync(`${componentsDirectory}/${fileOrDirectory}`).isDirectory()
) {
acc[1].push(fileOrDirectory)
} else {
acc[0].push(fileOrDirectory)
}
return acc
},
[[], []],
)
// Write them
fs.writeFileSync(
indexFile,
filesAndDirectories
.reduce((acc, fileOrDirectory) => {
if (['.DS_Store', 'index.ts'].includes(fileOrDirectory)) {
return [...acc]
}
const componentName = fileOrDirectory.replace('.tsx', '')
return [...acc, `export { ${componentName} } from './${componentName}'`]
}, [])
.join('\n'),
{
flag: 'w',
},
)
console.log(`Rebuilt component index file at ${indexFile}`)

View file

@ -0,0 +1,24 @@
import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'
interface NewComponentProps extends ComponentProps<'div'> {}
export function NewComponent({
children,
className,
...otherProps
}: NewComponentProps) {
return (
<div
className={twMerge(
`
`,
className,
)}
{...otherProps}
>
{children}
</div>
)
}

View file

@ -0,0 +1,20 @@
import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
interface NewComponentProps extends ComponentProps<'div'> {}
export function NewComponent({
children,
className,
...otherProps
}: NewComponentProps) {
return (
<div
className={twMerge(classNames.container, className)}
{...otherProps}
>
{children}
</div>
)
}

View file

@ -0,0 +1,7 @@
import { tw } from '@/lib/tw'
export const classNames = {
container: tw`
// styles here
`,
}

View file

@ -0,0 +1 @@
export { NewComponent } from './NewComponent'

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -0,0 +1,66 @@
import { App } from '@/components'
import type { Metadata } from 'next'
import { Raleway } from 'next/font/google'
import Script from 'next/script'
import { twMerge } from 'tailwind-merge'
import './globals.css'
const bodyFont = Raleway({ subsets: ['latin'], variable: '--font-raleway' })
export const metadata: Metadata = {
title: 'Cycles: Respect the Graph',
description: 'The Open Clearing Protocol.',
icons: [
{
rel: 'icon',
type: 'image/png',
sizes: '32x32',
url: '/favicon.png',
},
],
openGraph: {
type: 'website',
url: 'https://example.com',
title: 'Cycles: Respect the Graph',
description: 'The Open Clearing Protocol.',
siteName: 'Cycles',
images: [
{
url: 'http://cycles.money/share-sheet-image.jpg',
},
],
},
twitter: {
card: 'summary_large_image',
site: '@site',
creator: '@creator',
images: 'http://cycles.money/share-sheet-image.jpg',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
<Script src="https://kit.fontawesome.com/ddaf2d7713.js" />
</head>
<body
className={twMerge(
`
overflow-x-hidden
bg-appBgColor
text-textColor
`,
bodyFont.className,
bodyFont.variable,
)}
>
<App>{children}</App>
</body>
</html>
)
}

View file

@ -0,0 +1,229 @@
'use client'
import { useEffect, useState } from 'react'
import { isEmpty } from 'lodash'
import {
DepositModalWindow,
Icon,
Notifications,
StyledText,
TransferModalWindow,
WithdrawModalWindow,
} from '@/components'
import { tw } from '@/lib/tw'
import { wasmEventHandler } from '@/lib/wasmEventHandler'
import { FormActionResponse } from '@/lib/types'
import { chain } from '@/lib/chainConfig'
import { cosm } from '@/lib/cosm'
import { wallet } from '@/lib/wallet'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
function formatAmount(value: number) {
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: chain.stakeCurrency?.coinDecimals,
})
}
// Safe method to get the balance amount from the decrypted data
const retrieveBalance = (data: string) => {
let balance = 0
if (!isEmpty(data)) {
const json = JSON.parse(data)
balance = Number(json.balance ?? 0)
}
return balance
}
export default function Home() {
const [requestBalanceResult, setRequestBalanceResult] =
useState<FormActionResponse>()
const [balance, setBalance] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [walletAddress, setWalletAddress] = useState(
wallet.getAccount().address,
)
const [isDepositModalOpen, setIsDepositModalOpen] = useState(false)
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false)
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false)
// Listen for Keplr wallet switches so we can refresh the Keplr & CosmWasm info
useEffect(() => {
const params: [string, () => void] = [
'keplr_keystorechange',
() => {
setIsLoading(true)
wallet
.refreshUser()
.then(cosm.init)
.finally(() => {
setWalletAddress(wallet.getAccount().address)
setIsLoading(false)
})
},
]
window.addEventListener(...params)
return () => window.removeEventListener(...params)
}, [])
// Set the current balance for the wallet. Whenever the wallet changes, we retrieve its balance
useEffect(() => {
cosm
.queryTransferContract({
messageBuilder: () => contractMessageBuilders.getBalance(walletAddress),
})
.then((data) => setBalance(retrieveBalance(wallet.decrypt(data))))
}, [walletAddress])
// Listen for the response event from the blockchain when requesting the current wallet new balance
useEffect(() => {
return wasmEventHandler(
`execute._contract_address='${process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS}' AND wasm-store_balance.address='${walletAddress}'`,
{
next: (event) => {
console.log(event)
if (!isEmpty(event?.events['wasm-store_balance.encrypted_balance'])) {
setBalance(
retrieveBalance(
wallet.decrypt(
event.events['wasm-store_balance.encrypted_balance'][0],
),
),
)
}
},
},
)
}, [walletAddress])
// Request the current wallet new balance calling the transfer contract
async function requestBalance(): Promise<void> {
let result
try {
setIsLoading(true)
const response = await cosm.executeTransferContract({
messageBuilder: () =>
contractMessageBuilders.requestBalance(wallet.getKeypair().pubkey),
})
console.log(response)
setIsLoading(false)
result = {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
setIsLoading(false)
result = {
success: false,
messages: ['Something went wrong'],
}
} finally {
setRequestBalanceResult(result)
}
}
return (
<main
className={tw`
flex
h-screen
flex-col
items-center
justify-center
bg-[url(/images/moroccan-flower.png)]
p-12
`}
>
<Notifications formActionResponse={requestBalanceResult} />
<div
className={tw`
flex
flex-col
gap-2
divide-y
rounded-md
border
border-black/20
bg-white
p-5
py-3
shadow-2xl
`}
>
<div className="flex w-full justify-between">
<span className="font-bold">Balance:</span>
<span className="font-bold">{formatAmount(balance)}</span>
</div>
<StyledText
className="bg-blue-500 font-bold"
variant="button.primary"
as="button"
disabled={isLoading}
onClick={requestBalance}
>
{!isLoading ? (
<Icon name="building-columns" />
) : (
<div className="animate-spin">
<Icon name="spinner" />
</div>
)}
Get Balance
</StyledText>
<div className="my-1 w-full border-black/20"></div>
<StyledText
className="w-full bg-emerald-500 font-bold"
variant="button.primary"
onClick={() => setIsDepositModalOpen(true)}
>
<Icon name="piggy-bank" />
Deposit
</StyledText>
<StyledText
variant="button.primary"
className="w-full font-bold"
onClick={() => setIsTransferModalOpen(true)}
>
<Icon name="arrows-left-right" />
Transfer
</StyledText>
<StyledText
className="w-full bg-amber-500 font-bold"
variant="button.primary"
onClick={() => setIsWithdrawModalOpen(true)}
>
<Icon name="money-bills-simple" />
Withdraw
</StyledText>
</div>
<DepositModalWindow
isOpen={isDepositModalOpen}
onClose={() => setIsDepositModalOpen(false)}
/>
<TransferModalWindow
isOpen={isTransferModalOpen}
onClose={() => setIsTransferModalOpen(false)}
/>
<WithdrawModalWindow
isOpen={isWithdrawModalOpen}
onClose={() => setIsWithdrawModalOpen(false)}
/>
</main>
)
}

View file

@ -0,0 +1,27 @@
'use client'
import { ReactNode, useEffect, useState } from 'react'
import { wallet } from '@/lib/wallet'
import { cosm } from '@/lib/cosm'
import { LoadingSpinner } from './LoadingSpinner'
// Root component
export function App({ children }: { children: ReactNode }) {
const [isInit, setIsInit] = useState(false)
useEffect(() => {
// Inititalize Keplr and CosmWasm wrappers
wallet
.init()
.then(cosm.init)
.then(() => setIsInit(true))
}, [])
return (
<>
<LoadingSpinner isLoading={!isInit} />
{isInit && children}
</>
)
}

View file

@ -0,0 +1,107 @@
'use client'
import { ChangeEvent, useActionState, useState } from 'react'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledBox } from '@/components/StyledBox'
import { StyledText } from '@/components/StyledText'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
import { cosm } from '@/lib/cosm'
import { tw } from '@/lib/tw'
import { FormActionResponse } from '@/lib/types'
// Deposit the specified amount calling the Transfer contract
async function handleDeposit(
_: FormActionResponse,
formData: FormData,
): Promise<FormActionResponse> {
const amount = String(formData.get('amount'))
try {
const result = await cosm.executeTransferContract({
messageBuilder: contractMessageBuilders.deposit,
fundsAmount: amount,
})
console.log(result)
return {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
return {
success: false,
messages: ['Something went wrong'],
}
}
}
export function DepositModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [amount, setAmount] = useState(0)
const [formActionResponse, formAction, isLoading] = useActionState(
handleDeposit,
null,
)
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
>
<LoadingSpinner isLoading={isLoading} />
<ModalWindow.Title className="bg-emerald-500">Deposit</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
<StyledBox
as="input"
className={tw`
focus:!border-emerald-500
focus:!outline-emerald-500
focus:!ring-emerald-500
`}
min={0}
name="amount"
placeholder="0.00"
type="number"
value={amount || ''}
variant="input"
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setAmount(Number(event.target.value))
}
/>
</ModalWindow.Body>
<ModalWindow.Buttons>
<StyledText
as="button"
className="bg-emerald-500"
disabled={amount === 0}
variant="button.primary"
>
Deposit
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -0,0 +1,39 @@
import { twMerge } from 'tailwind-merge'
import { IconProps } from './types'
const Icon = ({
className,
name,
rotate,
spin = false,
variant = 'solid',
...otherProps
}: IconProps) => (
<span
className={twMerge(`!no-underline`, className)}
{...otherProps}
>
<i
className={twMerge(
`
fa
fa-fw
fa-${name}
`,
typeof rotate === 'string' && `fa-${rotate}`,
typeof rotate === 'number' && `fa-rotate-${rotate}`,
spin && `fa-spin`,
variant.startsWith('sharp-')
? `
fa-sharp
fa-${variant.replace('sharp-', '')}
`
: `
fa-${variant}
`,
)}
/>
</span>
)
export { Icon }

View file

@ -0,0 +1,9 @@
export { Icon } from "./Icon"
export type {
BrandIconName,
IconName,
IconProps,
IconRotationOption,
IconVariant,
RegularIconName,
} from "./types"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
import { Icon } from '@/components/Icon'
import { ComponentProps } from 'react'
import { twMerge } from 'tailwind-merge'
interface LoadingSpinnerProps extends Omit<ComponentProps<'div'>, 'children'> {
isLoading: boolean
}
export function LoadingSpinner({
className,
isLoading,
...otherProps
}: LoadingSpinnerProps) {
return (
<div
className={twMerge(
`
pointer-events-none
absolute
bottom-0
left-0
right-0
top-0
z-20
flex
items-center
justify-center
bg-appBgColor
transition-all
`,
isLoading
? `
opacity-100
`
: `
opacity-0
`,
className,
)}
{...otherProps}
>
<div className="animate-spin">
<Icon name="spinner" />
</div>
</div>
)
}

View file

@ -0,0 +1,132 @@
'use client'
import { ComponentProps, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
export interface ModalWindowProps extends ComponentProps<'div'> {
isOpen: boolean
onClose: () => void
}
export function ModalWindow({
children,
className,
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [isClient, setIsClient] = useState(false)
const [modalState, setModalState] = useState<
'opening' | 'open' | 'closing' | 'closed'
>('closed')
const windowContentsContainerRef = useRef<HTMLDivElement>(null)
function handleTransitionEnd() {
if (modalState === 'closing') {
setModalState('closed')
onClose()
}
if (modalState === 'opening') {
setModalState('open')
}
}
function handleClose() {
setModalState('closing')
}
function focusFirstElement() {
const windowContentsContainer = windowContentsContainerRef.current
if (!windowContentsContainer) {
return
}
const firstFocusableElement = windowContentsContainer.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
) as HTMLElement
if (firstFocusableElement) {
firstFocusableElement.focus()
}
}
useEffect(() => {
setIsClient(true)
}, [])
useEffect(() => {
if (isOpen) {
setModalState('opening')
focusFirstElement()
} else {
setModalState('closing')
}
}, [isOpen])
useEffect(() => {
if (isOpen) {
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleClose()
}
}
window.addEventListener('keydown', handleEscape)
return () => {
window.removeEventListener('keydown', handleEscape)
}
}
}, [isOpen, onClose])
if (!isClient) {
return null
}
return createPortal(
<>
<div
className={classNames.backdrop({ modalState })}
onClick={handleClose}
/>
<div
className={twMerge(classNames.container({ modalState }), className)}
ref={windowContentsContainerRef}
onTransitionEnd={handleTransitionEnd}
{...otherProps}
>
{children}
</div>
</>,
document.body,
)
}
ModalWindow.Title = function ModalWindowTitle({
children,
className,
}: ComponentProps<'header'>) {
return <div className={twMerge(classNames.header, className)}>{children}</div>
}
ModalWindow.Body = function ModalWindowBody({
children,
className,
}: ComponentProps<'main'>) {
return <div className={twMerge(classNames.body, className)}>{children}</div>
}
ModalWindow.Buttons = function ModalWindowBody({
children,
className,
}: ComponentProps<'div'>) {
return (
<div className={twMerge(classNames.buttons, className)}>{children}</div>
)
}

View file

@ -0,0 +1,79 @@
import { tw } from "@/lib/tw";
import { twMerge } from "tailwind-merge";
export const classNames = {
backdrop: ({ modalState = 'closed' }) => twMerge(
`
fixed
left-0
top-0
h-screen
w-full
transition-all
z-10
duration-500
`,
(modalState === 'opening' || modalState === 'open')
?
`
opacity-100
backdrop-blur-md
pointer-events-auto
bg-shadedBgColor/20
`
:
`
opacity-0
backdrop-blur-none
pointer-events-none
bg-transparent
`
),
container: ({ modalState = 'closed' }) => twMerge(
`
fixed
left-1/2
top-1/2
bg-appBgColor
rounded-lg
overflow-hidden
-translate-x-1/2
z-10
duration-500
min-w-64
shadow-xl
`,
(modalState === 'opening' || modalState === 'open')
?
`
opacity-100
-translate-y-1/2
`
:
`
opacity-0
pointer-events-none
-translate-y-full
`
),
header: tw`
px-3
py-1
bg-accentColor
text-white
font-bold
`,
body: tw`
p-3
`,
buttons: tw`
flex
flex-row-reverse
gap-1
p-3
`
}

View file

@ -0,0 +1 @@
export { ModalWindow, type ModalWindowProps } from './ModalWindow'

View file

@ -0,0 +1,140 @@
'use client'
import { Icon } from '@/components/Icon'
import { StyledText } from '@/components/StyledText'
import { FormActionResponse } from '@/lib/types'
import { uniqueId } from 'lodash'
import { ComponentProps, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
interface NotificationsProps extends Omit<ComponentProps<'ul'>, 'children'> {
formActionResponse: FormActionResponse | undefined
}
type NotificationState = 'entering' | 'entered' | 'exiting' | 'exited'
export function Notifications({
className,
formActionResponse,
...otherProps
}: NotificationsProps) {
const [storedMessages, setStoredMessages] = useState<
{ id: number; message: string }[]
>([])
const [isClient, setIsClient] = useState(false)
const { success, messages } = formActionResponse || {
success: false,
messages: [],
}
useEffect(() => {
if (messages) {
setStoredMessages((currentStoredMessages) => [
...currentStoredMessages,
...messages.map((message) => ({
id: Number(uniqueId()),
message,
})),
])
}
}, [messages])
useEffect(() => {
setIsClient(true)
}, [])
function handleDismissMessage(id: number) {
setStoredMessages((currentStoredMessages) =>
currentStoredMessages.filter((message) => message.id !== id),
)
}
if (!isClient) {
return null
}
return createPortal(
<>
<div
className={classNames.backdrop({
hasMessages: storedMessages.length > 0,
success,
})}
/>
{messages && (
<ul
className={twMerge(classNames.container, className)}
{...otherProps}
>
{storedMessages.map(({ id, message }, index) => (
<Notifications.Notification
index={index}
key={id}
message={message}
success={success}
onDismiss={handleDismissMessage.bind(null, id)}
/>
))}
</ul>
)}
</>,
document.body,
)
}
Notifications.Notification = function Notification({
index,
message,
success,
onDismiss,
...otherProps
}: ComponentProps<'li'> & {
index: number
message: string
success: boolean
onDismiss: () => void
}) {
const [state, setState] = useState<NotificationState>('entering')
function handleAnimationEnd() {
if (state === 'entering') {
setState('entered')
}
if (state === 'exiting') {
setState('exited')
onDismiss()
}
}
return (
state !== 'exited' && (
<li
className={classNames.notificationContainer({ state })}
style={{
animationDelay: `${index * 0.1}s`,
}}
onAnimationEnd={handleAnimationEnd}
{...otherProps}
>
<div className={classNames.notificationSurface({ success })}>
<Icon name={success ? 'circle-check' : 'circle-exclamation'} />
<div>{message}</div>
<div>
<StyledText
as="button"
variant="button.tool"
onClick={() => setState('exiting')}
>
<Icon name="xmark" />
</StyledText>
</div>
</div>
</li>
)
)
}

View file

@ -0,0 +1,90 @@
import { tw } from '@/lib/tw'
import { twMerge } from 'tailwind-merge'
export const classNames = {
backdrop: ({ hasMessages = false, success = false }) =>
twMerge(
`
fixed
bottom-0
right-0
h-[20vh]
w-[50vw]
bg-gradient-to-tl
via-transparent
to-transparent
opacity-0
transition-opacity
duration-500
z-10
`,
success ? 'from-green-400/50' : 'from-red-400/50',
hasMessages &&
`
opacity-100
`,
),
container: tw`
fixed
bottom-0
right-0
flex
flex-col-reverse
gap-2
p-6
z-20
`,
notificationContainer: ({ state = 'entering' }) =>
twMerge(
`
w-[20rem]
overflow-hidden
transition-all
`,
state === 'entering' &&
`
animate-animateInX
`,
state === 'entered' &&
`
opacity-100
shadow-md
`,
state === 'exiting' &&
`
animate-animateOutX
`,
state === 'exited' &&
`
hidden
`,
),
notificationSurface: ({ success = false }) =>
twMerge(
`
grid
w-full
grid-cols-[auto,1fr,auto]
gap-3
rounded-md
border-2
px-3
py-3
text-sm
text-white
transition-all
`,
success
? `
border-green-400
bg-green-500
`
: `
border-red-400
bg-red-500
`,
),
}

View file

@ -0,0 +1 @@
export { Notifications } from './Notifications'

View file

@ -0,0 +1,29 @@
import { ComponentProps, ElementType } from 'react'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
type StyledBoxVariant = keyof typeof classNames
type StyledBoxProps<T extends ElementType = 'div'> = Omit<
ComponentProps<T>,
'variant'
> & {
as?: T
variant?: StyledBoxVariant
}
export function StyledBox<T extends ElementType = 'div'>({
as,
className,
variant,
...otherProps
}: StyledBoxProps<T>) {
const Component = as || 'div'
return (
<Component
className={twMerge(variant && classNames[variant], className)}
{...otherProps}
/>
)
}

View file

@ -0,0 +1,38 @@
import { tw } from '@/lib/tw'
export const classNames = {
container: tw`
container
mx-auto
max-sm:px-6
md:max-w-screen-sm
lg:max-w-screen-md
xl:max-w-screen-lg
2xl:max-w-screen-xl
`,
checkbox: tw`
text-bgColor
size-7
rounded-md
border-2
border-borderColor
!outline-accentColor
!ring-accentColor
checked:text-accentColor
`,
input: tw`
w-full
rounded-md
border
border-borderColor
bg-transparent
px-3
py-2
!outline-accentColor
!ring-accentColor
focus:border-accentColor
[appearance:textfield]
`,
}

View file

@ -0,0 +1 @@
export { StyledBox } from './StyledBox'

View file

@ -0,0 +1,29 @@
import { ComponentProps, ElementType } from 'react'
import { twMerge } from 'tailwind-merge'
import { classNames } from './classNames'
type StyledTextVariant = keyof typeof classNames
type StyledTextProps<T extends ElementType = 'span'> = Omit<
ComponentProps<T>,
'variant'
> & {
as?: T
variant?: StyledTextVariant
}
export function StyledText<T extends ElementType = 'span'>({
as,
className,
variant,
...otherProps
}: StyledTextProps<T>) {
const Component = as || 'span'
return (
<Component
className={twMerge(variant && classNames[variant], className)}
{...otherProps}
/>
)
}

View file

@ -0,0 +1,153 @@
import { tw } from '@/lib/tw'
import { twJoin, twMerge } from 'tailwind-merge'
const buttonClassNames = tw`
inline-flex
w-min
cursor-pointer
items-center
justify-center
gap-2
transition-all
hover:scale-105
disabled:pointer-events-none
disabled:opacity-40
`
const headingClassNames = tw`
font-medium
[&_span]:font-display
[&_span]:font-medium
[&_span]:italic
[&_span]:text-accentColor
`
export const classNames = {
'button.primary': twMerge(
buttonClassNames,
`
whitespace-nowrap
rounded-md
bg-accentColor
px-3
py-1
text-white
`,
),
'button.secondary': twMerge(
buttonClassNames,
`
relative
z-10
whitespace-nowrap
rounded-md
border
bg-white
px-3
py-1
text-textColor
backdrop-blur-sm
`,
),
'button.icon': twMerge(
buttonClassNames,
`
size-10
rounded-full
bg-shadedBgColor
hover:bg-accentColor
hover:text-appBgColor
`,
),
'button.tool': tw`
inline-flex
items-center
justify-center
gap-2
rounded-sm
px-1
py-0.5
text-sm
text-textColor
hover:bg-textColor/15
`,
'footnote': tw`
text-sm
text-fadedTextColor
`,
'h1': twJoin(
headingClassNames,
`
text-5xl
`,
),
'h2': twJoin(
headingClassNames,
`
text-4xl
`,
),
'h3': twJoin(
headingClassNames,
`
text-3xl
`,
),
'h4': twJoin(
headingClassNames,
`
text-lg
`,
),
'label': tw`
text-sm
text-fadedTextColor
`,
'link': twMerge(
buttonClassNames,
`
text-accentColor
underline
underline-offset-4
transition-all
hover:underline-offset-4
`,
),
'link.subtle': twMerge(
buttonClassNames,
`
text-inherit
underline-offset-4
transition-all
hover:text-accentColor
hover:underline
hover:underline-offset-4
`,
),
'logo': tw`
inline-flex
items-center
justify-center
gap-[0.35em]
font-semibold
uppercase
tracking-[0.2em]
[&_span]:inline-block
[&_span]:border-l
[&_span]:pl-[0.5em]
[&_span]:font-light
[&_span]:tracking-[0]
`,
}

View file

@ -0,0 +1 @@
export { StyledText } from './StyledText'

View file

@ -0,0 +1,144 @@
'use client'
import { PublicKey, encrypt } from 'eciesjs'
import { ChangeEvent, useActionState, useState } from 'react'
import invariant from 'tiny-invariant'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledBox } from '@/components/StyledBox'
import { StyledText } from '@/components/StyledText'
import { cosm } from '@/lib/cosm'
import { FormActionResponse } from '@/lib/types'
import { wallet } from '@/lib/wallet'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
// Encrypt the transfer data using the enclave public key
function encryptMsg(data: {
sender: string
receiver: string
amount: string
}): string {
invariant(
process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY,
'NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY must be defined',
)
// Create the public key from the hex
const pubkey = PublicKey.fromHex(process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY)
// Convert the data into a JSON string
const serializedState = JSON.stringify(data)
// Encrypt the data
const encryptedState = encrypt(
pubkey.toHex(),
Buffer.from(serializedState, 'utf-8'),
)
return encryptedState.toString('hex')
}
// Transfer an amount between wallets calling the Transfer contract with an encrypted request
async function handleTransfer(
_: FormActionResponse,
formData: FormData,
): Promise<FormActionResponse> {
const receiver = String(formData.get('receiver'))
const amount = String(formData.get('amount'))
try {
const encryptedMsg = encryptMsg({
sender: wallet.getAccount().address,
receiver,
amount,
})
const result = await cosm.executeTransferContract({
messageBuilder: () => contractMessageBuilders.transfer(encryptedMsg),
})
console.log(result)
return {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
return {
success: false,
messages: ['Something went wrong'],
}
}
}
export function TransferModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [amount, setAmount] = useState(0)
const [receiver, setRecipient] = useState('')
const [formActionResponse, formAction, isLoading] = useActionState(
handleTransfer,
null,
)
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
>
<LoadingSpinner isLoading={isLoading} />
<ModalWindow.Title>Transfer</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
<StyledBox
as="input"
placeholder="recipient address"
type="text"
variant="input"
name="receiver"
value={receiver}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setRecipient(event.target.value)
}
/>
<StyledBox
as="input"
min={0}
name="amount"
placeholder="0.00"
type="number"
value={amount || ''}
variant="input"
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setAmount(Number(event.target.value))
}
/>
</ModalWindow.Body>
<ModalWindow.Buttons>
<StyledText
as="button"
disabled={amount === 0}
variant="button.primary"
>
Transfer
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -0,0 +1,78 @@
'use client'
import { useActionState } from 'react'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
import { Notifications } from '@/components/Notifications'
import { StyledText } from '@/components/StyledText'
import { cosm } from '@/lib/cosm'
import { FormActionResponse } from '@/lib/types'
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
// Withdraw all funds from the wallet balance calling the Transfer contract
async function handleWithdraw(): Promise<FormActionResponse> {
try {
const result = await cosm.executeTransferContract({
messageBuilder: contractMessageBuilders.withdraw,
})
console.log(result)
return {
success: true,
messages: ['woo!'],
}
} catch (error) {
console.error(error)
return {
success: false,
messages: ['Something went wrong'],
}
}
}
export function WithdrawModalWindow({
isOpen,
onClose,
...otherProps
}: ModalWindowProps) {
const [formActionResponse, formAction, isLoading] = useActionState(
handleWithdraw,
null,
)
return (
<ModalWindow
isOpen={isOpen}
onClose={onClose}
{...otherProps}
>
<LoadingSpinner isLoading={isLoading} />
<ModalWindow.Title className="bg-amber-500">Withdraw</ModalWindow.Title>
<form action={formAction}>
<ModalWindow.Body className="space-y-3">
<Notifications formActionResponse={formActionResponse} />
<p>This will return the entire remaining balance to your wallet.</p>
</ModalWindow.Body>
<ModalWindow.Buttons>
<StyledText
as="button"
className="bg-amber-500"
variant="button.primary"
>
Withdraw
</StyledText>
<StyledText
variant="button.secondary"
onClick={onClose}
>
Cancel
</StyledText>
</ModalWindow.Buttons>
</form>
</ModalWindow>
)
}

View file

@ -0,0 +1,10 @@
export { App } from './App'
export { DepositModalWindow } from './DepositModalWindow'
export { Icon } from './Icon'
export { LoadingSpinner } from './LoadingSpinner'
export { ModalWindow } from './ModalWindow'
export { Notifications } from './Notifications'
export { StyledBox } from './StyledBox'
export { StyledText } from './StyledText'
export { TransferModalWindow } from './TransferModalWindow'
export { WithdrawModalWindow } from './WithdrawModalWindow'

View file

@ -0,0 +1,57 @@
import { ChainInfo } from '@keplr-wallet/types'
import invariant from 'tiny-invariant'
invariant(
process.env.NEXT_PUBLIC_CHAIN_ID,
'NEXT_PUBLIC_CHAIN_ID must be defined',
)
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
invariant(
process.env.NEXT_PUBLIC_CHAIN_REST_URL,
'NEXT_PUBLIC_CHAIN_REST_URL must be defined',
)
// Testchain definition
export const chain: ChainInfo = {
rpc: process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
rest: process.env.NEXT_PUBLIC_CHAIN_REST_URL,
chainId: process.env.NEXT_PUBLIC_CHAIN_ID,
chainName: 'My Testing Chain',
stakeCurrency: {
coinDenom: 'COSM',
coinMinimalDenom: 'ucosm',
coinDecimals: 6,
coinGeckoId: 'regen',
},
bip44: {
coinType: 118,
},
bech32Config: {
bech32PrefixAccAddr: 'wasm',
bech32PrefixAccPub: 'wasm' + 'pub',
bech32PrefixValAddr: 'wasm' + 'valoper',
bech32PrefixValPub: 'wasm' + 'valoperpub',
bech32PrefixConsAddr: 'wasm' + 'valcons',
bech32PrefixConsPub: 'wasm' + 'valconspub',
},
currencies: [
{
coinDenom: 'COSM',
coinMinimalDenom: 'ucosm',
coinDecimals: 6,
coinGeckoId: 'regen',
},
],
feeCurrencies: [
{
coinDenom: 'COSM',
coinMinimalDenom: 'ucosm',
coinDecimals: 6,
coinGeckoId: 'regen',
gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 },
},
],
}

View file

@ -0,0 +1,28 @@
// Transfer contract operation message formats
// This is the whole definition of how the Tranfers contract expects to receive messages
export const contractMessageBuilders: {
deposit: () => {}
getBalance: (address: string) => {
get_balance: { address: string }
}
requestBalance: (pubkey: Uint8Array) => {}
transfer: (ciphertext: string) => {
transfer_request: {
ciphertext: string
digest: string
}
}
withdraw: () => {}
} = {
deposit: () => 'deposit',
getBalance: (address: string) => ({
get_balance: { address },
}),
requestBalance: (pubkey: Uint8Array) => ({
query_request: { emphemeral_pubkey: Buffer.from(pubkey).toString('hex') },
}),
transfer: (ciphertext: string) => ({
transfer_request: { ciphertext, digest: '' },
}),
withdraw: () => 'withdraw',
}

View file

@ -0,0 +1,91 @@
import { toUtf8 } from '@cosmjs/encoding'
import { Registry } from '@cosmjs/proto-signing'
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
import { coins } from '@cosmjs/stargate'
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx'
import invariant from 'tiny-invariant'
import { wallet } from './wallet'
const typeUrl = '/cosmwasm.wasm.v1.MsgExecuteContract'
const registry = new Registry([[typeUrl, MsgExecuteContract]])
// Cosm variables declaration. They will be set upon initialization.
let signingCosmClient: SigningCosmWasmClient
// Setup the CosmWasm client.
const init = async () => {
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
// Initialize Cosm client.
signingCosmClient = await SigningCosmWasmClient.connectWithSigner(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
wallet.getSigner(),
{ registry },
)
}
// Transfer contract execution message
const executeTransferContract = ({
messageBuilder,
fundsAmount,
}: {
messageBuilder: Function
fundsAmount?: string
}) => {
invariant(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
)
const sender = wallet.getAccount().address
// Prepare execution message to send
const executeTransferContractMsgs = [
{
typeUrl,
value: MsgExecuteContract.fromPartial({
sender,
contract: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
msg: toUtf8(JSON.stringify(messageBuilder())),
...(fundsAmount && {
funds: [{ denom: 'ucosm', amount: fundsAmount }],
}),
}),
},
]
// Send message
return signingCosmClient.signAndBroadcast(
sender,
executeTransferContractMsgs,
{
amount: coins(1, 'ucosm'),
gas: '200000',
},
)
}
// Transfer contract query message
const queryTransferContract = ({
messageBuilder,
}: {
messageBuilder: Function
}) => {
invariant(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
)
// Send message
return signingCosmClient.queryContractSmart(
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
messageBuilder(),
)
}
// Define the Cosm wrapper interface to be used
export const cosm = {
executeTransferContract,
init,
queryTransferContract,
}

View file

@ -0,0 +1,23 @@
import {
Bip39,
EnglishMnemonic,
Random,
Secp256k1,
Secp256k1Keypair,
} from '@cosmjs/crypto'
const storageKey = 'ephemeral-mnemonic'
// Retrieve from localstorage a mnemonic or create it if none exists to be used
// as input to generate an ephemeral key pair to encryp/decrypt messages for the user
export async function getEphemeralKeypair(): Promise<Secp256k1Keypair> {
const storedMnemonic = localStorage.getItem(storageKey)
const mnemonic =
storedMnemonic ?? Bip39.encode(Random.getBytes(32)).toString()
if (!storedMnemonic) {
localStorage.setItem(storageKey, mnemonic)
}
return Secp256k1.makeKeypair(Bip39.decode(new EnglishMnemonic(mnemonic)))
}

View file

@ -0,0 +1,3 @@
export function tw(strings: TemplateStringsArray, ...values: any[]) {
return String.raw({ raw: strings }, ...values)
}

View file

@ -0,0 +1,10 @@
export type FormActionResponse =
| null
| {
success: true
messages?: string[]
}
| {
success: false
messages: string[]
}

View file

@ -0,0 +1,68 @@
import { AccountData, Keplr, Window } from '@keplr-wallet/types'
import { OfflineSigner } from '@cosmjs/proto-signing'
import { Secp256k1Keypair } from '@cosmjs/crypto'
import { decrypt as _decrypt } from 'eciesjs'
import { isEmpty } from 'lodash'
import invariant from 'tiny-invariant'
import { chain } from './chainConfig'
import { getEphemeralKeypair } from './getEphemeralKeypair'
// Wallet variables declaration. They will be set upon initialization
let keplr: Keplr | undefined
let signer: OfflineSigner
let account: AccountData
let keypair: Secp256k1Keypair
// Setup the Keprl Wallet wrapper
const init = async () => {
keplr = (window as Window).keplr
invariant(
process.env.NEXT_PUBLIC_CHAIN_ID,
'NEXT_PUBLIC_CHAIN_ID must be defined',
)
invariant(keplr, 'No Keplr wallet found')
// Generate user ephemeral key pair for decryption
keypair = await getEphemeralKeypair()
// Set chain info in Keplr
await keplr.experimentalSuggestChain(chain)
// Init chain in Keplr
await keplr.enable(process.env.NEXT_PUBLIC_CHAIN_ID)
await refreshUser()
}
// Decrypt data using the ephemeral private key
const decrypt = (data: string): string => {
let result = ''
if (!isEmpty(data)) {
result = _decrypt(
wallet.getKeypair().privkey,
Buffer.from(data, 'hex'),
).toString()
}
return result
}
// Get the current Keprl Wallet account
const getAccount = (): AccountData => account
// Get the autogenerated ephemeral keypair
const getKeypair = (): Secp256k1Keypair => keypair
// Get the current Keprl Wallet signer
const getSigner = (): OfflineSigner => signer
// Update new Keprl Wallet user info
const refreshUser = async (): Promise<void> => {
signer = keplr!.getOfflineSigner(process.env.NEXT_PUBLIC_CHAIN_ID!)
account = (await signer.getAccounts())[0]
}
// Define the Keprl Wallet wrapper interface to be used
export const wallet = {
decrypt,
init,
getAccount,
getKeypair,
getSigner,
refreshUser,
}

View file

@ -0,0 +1,34 @@
import { WebsocketClient } from '@cosmjs/tendermint-rpc'
import invariant from 'tiny-invariant'
import { Listener } from 'xstream'
// Connect and listen to blockchain events
export const wasmEventHandler = (
query: string,
listener: Partial<Listener<any>>,
): (() => void) => {
invariant(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
)
// Create websocket connection to terdermint
const websocketClient = new WebsocketClient(
process.env.NEXT_PUBLIC_CHAIN_RPC_URL.replace('http', 'ws'),
)
// Listen to target query
websocketClient
.listen({
jsonrpc: '2.0',
method: 'subscribe',
params: { query },
// Just use some random UUID, we do not need to know which
id: crypto.randomUUID(),
})
.subscribe(listener)
// Callback method to call in case of cleaning
return () => {
websocketClient.disconnect()
}
}

View file

@ -0,0 +1,254 @@
import formsPlugin from '@tailwindcss/forms'
import typographyPlugin from '@tailwindcss/typography'
import type { Config } from 'tailwindcss'
import colors from 'tailwindcss/colors'
import plugin from 'tailwindcss/plugin'
const accentColorBucket = colors.violet
const neutralColorBucket = colors.stone
const borderColor = neutralColorBucket[200]
const innerRingStartOpacity = 0.8
const innerRingEndOpacity = 0.5
const outerRingStartOpacity = 0.5
const outerRingEndOpacity = 0.1
const config: Config = {
content: [
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
borderColor: {
DEFAULT: borderColor,
},
outlineColor: {
DEFAULT: borderColor,
},
colors: {
accentColor: accentColorBucket[500],
appBgColor: neutralColorBucket[50],
borderColor,
neutral: neutralColorBucket,
textColor: neutralColorBucket[800],
fadedTextColor: neutralColorBucket[500],
shadedBgColor: accentColorBucket[100],
},
fontFamily: {
body: ['var(--font-raleway)', 'sans-serif'],
display: ['var(--font-bitter)', 'sans-serif'],
icon: ["'Font Awesome 6 Pro'"],
},
animation: {
animateInX: 'animateInX 0.5s ease-in-out both',
animateInY: 'animateInY 0.5s ease-in-out both',
animateOutX: 'animateOutX 0.5s ease-in-out both',
animateOutY: 'animateOutY 0.5s ease-in-out both',
float: 'float 2.5s ease-in-out infinite alternate',
floatUp: 'floatUp 120s linear infinite',
forceFieldSmallInner:
'forceFieldSmallInner 2s ease-in-out infinite alternate',
forceFieldSmallOuter:
'forceFieldSmallOuter 2s ease-in-out infinite alternate',
forceFieldInner: 'forceFieldInner 2s ease-in-out infinite alternate',
forceFieldOuter: 'forceFieldOuter 2s ease-in-out infinite alternate',
rotatingSphere: 'rotatingSphere 120s linear infinite',
slowDriftLeft: 'driftLeft 60s linear infinite',
slowerDriftLeft: 'driftLeft 120s linear infinite',
slowestDriftLeft: 'driftLeft 180s linear infinite',
slowTwinkle: 'twinkle 0.25s linear infinite alternate',
slowerTwinkle: 'twinkle 0.5s linear infinite alternate',
wobble: 'wobble 2s ease-in-out infinite alternate',
},
keyframes: {
animateInX: {
'0%': {
opacity: '0',
transform: 'translateX(100%)',
},
'100%': {
opacity: '1',
transform: 'translateX(0)',
},
},
animateOutX: {
'0%': {
maxHeight: '200px',
opacity: '1',
transform: 'translateX(0%)',
},
'70%': {
maxHeight: '200px',
opacity: '0',
transform: 'translateX(100%)',
},
'100%': {
maxHeight: '0',
opacity: '0',
transform: 'translateX(100%)',
},
},
animateInY: {
'0%': {
opacity: '0',
transform: 'translateY(100%)',
},
'100%': {
opacity: '1',
transform: 'translateY(0)',
},
},
animateOutY: {
'0%': {
opacity: '1',
transform: 'translateY(0%)',
},
'100%': {
opacity: '0',
transform: 'translateY(-100%)',
},
},
driftLeft: {
'0%': {
opacity: '0',
transform: 'translateX(0)',
},
'5%, 95%': {
opacity: '1',
},
'100%': {
opacity: '0',
transform: 'translateX(-120vw)',
},
},
float: {
'0%': {
transform: 'translateY(-3%)',
},
'100%': {
transform: 'translateY(3%)',
},
},
floatUp: {
'0%': {
transform: 'translateX(0) translateY(0)',
},
'100%': {
transform: 'translateX(10vw) translateY(-110vh) rotate(10deg)',
},
},
forceFieldSmallInner: {
'0%': {
opacity: String(innerRingStartOpacity),
strokeWidth: '5px',
},
'100%': {
opacity: String(innerRingEndOpacity),
strokeWidth: '20px',
},
},
forceFieldSmallOuter: {
'0%': {
opacity: String(outerRingStartOpacity),
strokeWidth: '10px',
},
'100%': {
opacity: String(outerRingEndOpacity),
strokeWidth: '30px',
},
},
forceFieldInner: {
'0%': {
opacity: String(innerRingStartOpacity),
strokeWidth: '10px',
},
'100%': {
opacity: String(innerRingEndOpacity),
strokeWidth: '60px',
},
},
forceFieldOuter: {
'0%': {
opacity: String(outerRingStartOpacity),
strokeWidth: '20px',
},
'100%': {
opacity: String(outerRingEndOpacity),
strokeWidth: '100px',
},
},
rotatingSphere: {
'0%': {
transform: 'rotate(0)',
},
'100%': {
transform: 'rotate(359deg)',
},
},
twinkle: {
'0%': {
opacity: '0.9',
},
'50%': {
opacity: '0.8',
},
'100%': {
opacity: '1',
},
},
wobble: {
'0%': {
transform: 'rotate(-3deg)',
},
'100%': {
transform: 'rotate(3deg)',
},
},
},
},
},
plugins: [
plugin(function ({ addBase, theme }) {
addBase({
'*': {
scrollbarColor: `${theme('colors.accentColor')} transparent`,
},
'*::-webkit-scrollbar': {
height: theme('spacing.2'),
width: theme('spacing.2'),
},
'*::-webkit-scrollbar-track': {
background: 'transparent',
},
'*::-webkit-scrollbar-thumb': {
background: theme('colors.accentColor'),
borderRadius: theme('spacing.8'),
},
'a, button, input, textarea': {
touchAction: 'manipulation',
},
})
}),
formsPlugin,
typographyPlugin,
],
}
export default config

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}