initial commit in remote repository
Signed-off-by: Uncle Fatso <uncle.fatso@ghostchain.io>
This commit is contained in:
commit
6906ca83b7
8
.eslintignore
Normal file
8
.eslintignore
Normal file
@ -0,0 +1,8 @@
|
||||
# don't ever lint node_modules
|
||||
node_modules
|
||||
# don't lint build output (make sure it's set to your correct build folder name)
|
||||
dist
|
||||
# don't lint .cache
|
||||
.cache
|
||||
|
||||
webpack.*
|
25
.eslintrc.cjs
Normal file
25
.eslintrc.cjs
Normal file
@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
settings: { react: { version: "detect" } },
|
||||
extends: ["react-app", "prettier"],
|
||||
rules: {
|
||||
"import/no-extraneous-dependencies": [
|
||||
"error",
|
||||
{
|
||||
devDependencies: ["**/*.test.ts", "**/*.spec.ts", "**/*.bench.ts"],
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-redeclare": "off",
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
},
|
||||
globals: {
|
||||
chrome: true,
|
||||
},
|
||||
}
|
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/tests
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
/public/manifest.json
|
||||
test-results
|
||||
playwright-report
|
||||
|
||||
# NOTE: removed temporarily
|
||||
/tests
|
74
README.md
Normal file
74
README.md
Normal file
@ -0,0 +1,74 @@
|
||||
# GHOST Wallet - Light Client Wallet
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
Welcome to GHOST Wallet - the ultimate light client wallet! GHOST Wallet leverages the power of a light client that implements the JSON RPC to provide a highly resilient, efficient, and user-friendly wallet experience. This extension will be able to instantly connect any DApp to GHOST and CASPER chains without the hassle of waiting for synchronization.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Download
|
||||
|
||||
You can download pre-build extension for browser of your choice [here](https://google.com).
|
||||
|
||||
- **Chromium-based Browsers** - Use [official guide](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world?#load-unpacked) to install
|
||||
- **Firefox-based Browsers** - To install extension temporary check official [guide here](https://brave.com/)
|
||||
|
||||
__**NOTE: if you are using [Brave Browser](https://brave.com/) go to `Options` right after the installation and follow the instructions**__
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **pnpm** - [Fast, disk space efficient package manager](https://pnpm.io/)
|
||||
|
||||
### Installation
|
||||
|
||||
Clone the repository and install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
# to build chrome extension
|
||||
pnpm build:chrome
|
||||
|
||||
# to build firefox extension
|
||||
pnpm build:firefox
|
||||
```
|
||||
|
||||
### Running the Wallet
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
This will open the extension in a browser window.
|
||||
|
||||
## How it Works
|
||||
|
||||
GHOST Wallet runs a single light client instance inside of the user's browser.
|
||||
When a dapp connects to GHOST Wallet, its connection to the blockchain is
|
||||
forwarded to that light client instance. Inside the extension, we use low level
|
||||
API libraries such as the `observable-client`, `substrate-client`, and `json-rpc-provider` to
|
||||
maintain a connection to light client.
|
||||
|
||||
If light client were to crash, GHOST Wallet will automatically re-connect.
|
||||
However, from the dapp perspective, you will be disconnected and it will be your
|
||||
responsibility to re-connect back to GHOST Wallet.
|
||||
|
||||
## Giving Feedback
|
||||
|
||||
If you have trouble integrating this wallet template or you have questions,
|
||||
please open an issue.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- [Substrate](https://docs.substrate.io/) - The blockchain framework that powers this extension.
|
||||
- [Smoldot](https://github.com/smol-dot/smoldot) - The light client used for connecting to blockchain.
|
27
assets/chainspecs/casper_staging_testnet.json
Normal file
27
assets/chainspecs/casper_staging_testnet.json
Normal file
File diff suppressed because one or more lines are too long
50
assets/chainspecs/polkadot.json
Normal file
50
assets/chainspecs/polkadot.json
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/icons/icon-128.png
Normal file
BIN
assets/icons/icon-128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
assets/icons/icon-16.png
Normal file
BIN
assets/icons/icon-16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 604 B |
BIN
assets/icons/icon-32.png
Normal file
BIN
assets/icons/icon-32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/icon-48.png
Normal file
BIN
assets/icons/icon-48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/img/ghostWallet-Featured-Image.png
Normal file
BIN
assets/img/ghostWallet-Featured-Image.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 336 KiB |
44
assets/manifest-v3-chrome.json
Normal file
44
assets/manifest-v3-chrome.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"author": "Ghost Team <someone@ghostchain.io>",
|
||||
"description": "Wallet for Ghost and Casper blockchains light clients",
|
||||
"homepage_url": "https://github.com/ghostchain/ghost-wallet",
|
||||
"name": "Ghost Wallet",
|
||||
"short_name": "ghost-wallet",
|
||||
"version": "0.0.0",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["notifications", "storage", "tabs", "alarms"],
|
||||
"background": {
|
||||
"service_worker": "background/background.js",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Ghost Wallet",
|
||||
"default_popup": "ui/assets/wallet-popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "ui/assets/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content/content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "./icons/icon-16.png",
|
||||
"32": "./icons/icon-32.png",
|
||||
"48": "./icons/icon-48.png",
|
||||
"128": "./icons/icon-128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["inpage/inpage.js"],
|
||||
"matches": ["http://*/*", "https://*/*"]
|
||||
}
|
||||
]
|
||||
}
|
49
assets/manifest-v3-firefox.json
Normal file
49
assets/manifest-v3-firefox.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"author": "Ghost Team <someone@ghostchain.io>",
|
||||
"description": "Wallet for Ghost and Casper blockchains light clients",
|
||||
"homepage_url": "https://github.com/ghostchain/ghost-wallet",
|
||||
"name": "Ghost Wallet",
|
||||
"short_name": "ghost-wallet",
|
||||
"version": "0.0.0",
|
||||
"manifest_version": 3,
|
||||
"permissions": ["notifications", "storage", "tabs", "alarms"],
|
||||
"background": {
|
||||
"scripts": ["background/background.js"],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_title": "Ghost Wallet",
|
||||
"default_popup": "ui/assets/wallet-popup.html"
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "ui/assets/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"js": ["content/content.js"],
|
||||
"matches": ["http://*/*", "https://*/*"],
|
||||
"run_at": "document_start"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "./icons/icon-16.png",
|
||||
"32": "./icons/icon-32.png",
|
||||
"48": "./icons/icon-48.png",
|
||||
"128": "./icons/icon-128.png"
|
||||
},
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{9b4d20ed-b18a-4237-b5d0-ca71c2ce2060}"
|
||||
}
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["inpage/inpage.js"],
|
||||
"matches": ["http://*/*", "https://*/*"]
|
||||
}
|
||||
]
|
||||
}
|
11
assets/options.html
Normal file
11
assets/options.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ghost Wallet Options</title>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body style="background-color: #f9f9f9">
|
||||
<div id="options"></div>
|
||||
<script type="module" src="../src/open-options.tsx"></script>
|
||||
</body>
|
||||
</html>
|
11
assets/wallet-popup.html
Normal file
11
assets/wallet-popup.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ghost Wallet</title>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="popup"></div>
|
||||
<script type="module" src="../src/wallet-popup.tsx"></script>
|
||||
</body>
|
||||
</html>
|
17
components.json
Normal file
17
components.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.json",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
20679
package-lock.json
generated
Normal file
20679
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
170
package.json
Normal file
170
package.json
Normal file
@ -0,0 +1,170 @@
|
||||
{
|
||||
"name": "ghost-chain-extension",
|
||||
"version": "0.0.26",
|
||||
"description": "Browser extension to manage ghost blockchain light clients.",
|
||||
"main": "dist/src/index.js",
|
||||
"author": "Uncle f4ts0 <f4ts0@ghostchain.io>",
|
||||
"maintainers": [
|
||||
"Uncle f4ts0 <fatso@ghostchain.io>",
|
||||
"Uncle 57r3tch <stretch@ghostchain.io>",
|
||||
"Uncle 5t1nky <stinky@ghostchain.io>"
|
||||
],
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"keywords": [],
|
||||
"scripts": {
|
||||
"prep": "mkdir dist && cp -r ./assets/icons ./dist && cp -r ./assets/chainspecs ./dist",
|
||||
"prep:chrome": "pnpm prep && node scripts/generateManifest.js ./assets/manifest-v3-chrome.json ./dist/manifest.json",
|
||||
"prep:firefox": "pnpm prep && node scripts/generateManifest.js ./assets/manifest-v3-firefox.json ./dist/manifest.json",
|
||||
"build": "tsc --noEmit && pnpm build:chrome && pnpm build:firefox",
|
||||
"build:chrome": "pnpm clean && pnpm prep:chrome && concurrently \"pnpm:build-*\" && node scripts/checkExtensionScriptSizes.js",
|
||||
"build:firefox": "pnpm clean && pnpm prep:firefox && concurrently \"pnpm:build-*\" && node scripts/checkExtensionScriptSizes.js",
|
||||
"build-content": "set INPUT=content && pnpm exec vite build --config vite.script.config.js",
|
||||
"build-background": "set INPUT=background && pnpm exec vite build --config vite.script.config.js",
|
||||
"build-inpage": "set INPUT=inpage && pnpm exec vite build --config vite.script.config.js",
|
||||
"build-ui": "vite build --config vite.ui.config.js",
|
||||
"dev": "pnpm dev:chrome",
|
||||
"dev:chrome": "pnpm clean && pnpm prep:chrome && concurrently \"pnpm:dev-*\"",
|
||||
"dev:firefox": "pnpm clean && pnpm prep:firefox && concurrently \"pnpm:dev-*\"",
|
||||
"dev-content": "pnpm build-content --mode development --watch",
|
||||
"dev-background": "pnpm build-background --mode development --watch",
|
||||
"dev-inpage": "pnpm build-inpage --mode development --watch",
|
||||
"dev-ui": "pnpm build-ui --mode development --watch",
|
||||
"lint": "eslint src --ext .js,.jsx,.ts,.tsx",
|
||||
"test": "vitest run",
|
||||
"start": "web-ext run --source-dir ./dist -t chromium",
|
||||
"start:firefox": "web-ext run --source-dir ./dist -t firefox-desktop",
|
||||
"clean": "rm -rf dist",
|
||||
"deep-clean": "pnpm clean && rm -rf node_modules",
|
||||
"playwright": "playwright test --ui",
|
||||
"playwright:install": "playwright install --with-deps chromium",
|
||||
"playwright:chromium": "playwright test --project=chromium"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,md}": "prettier --write"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.9",
|
||||
"@playwright/test": "^1.48.2",
|
||||
"@total-typescript/tsconfig": "^1.0.4",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/qrcode.react": "^1.0.5",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||
"@webext-core/storage": "^1.2.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"globals": "^16.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"nodemon": "^3.1.4",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tshy": "^3.0.2",
|
||||
"tslib": "^2.6.2",
|
||||
"tsup": "^8.3.0",
|
||||
"turbo": "^2.2.3",
|
||||
"typedoc": "^0.26.11",
|
||||
"typescript": "5.6.2",
|
||||
"vite": "^6.1.6",
|
||||
"vite-plugin-web-extension": "^4.4.3",
|
||||
"vite-tsconfig-paths": "^5.0.1",
|
||||
"vitest": "^2.1.9",
|
||||
"web-ext": "^8.2.0",
|
||||
"wxt": "^0.17.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.1.10",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.5.0",
|
||||
"@polkadot-api/codegen": "~0.13.2",
|
||||
"@polkadot-api/json-rpc-provider": "~0.0.4",
|
||||
"@polkadot-api/metadata-builders": "~0.13.0",
|
||||
"@polkadot-api/observable-client": "~0.8.6",
|
||||
"@polkadot-api/polkadot-signer": "~0.1.6",
|
||||
"@polkadot-api/signer": "~0.1.15",
|
||||
"@polkadot-api/substrate-bindings": "~0.15.0",
|
||||
"@polkadot-api/substrate-client": "~0.3.0",
|
||||
"@polkadot-api/utils": "~0.1.2",
|
||||
"@polkadot-api/view-builder": "~0.4.3",
|
||||
"@polkadot-labs/hdkd": "^0.0.11",
|
||||
"@polkadot-labs/hdkd-helpers": "^0.0.11",
|
||||
"@polkadot-labs/schnorrkel-wasm": "^0.0.7",
|
||||
"@polkadot/extension-inject": "^0.58.4",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.6",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-menubar": "^1.1.6",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slider": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@react-rxjs/core": "^0.10.7",
|
||||
"@react-rxjs/utils": "^0.9.7",
|
||||
"@substrate/connect-discovery": "^0.2.2",
|
||||
"@substrate/discovery": "^0.2.2",
|
||||
"@substrate/light-client-extension-helpers": "^2.7.6",
|
||||
"@substrate/smoldot-discovery-connector": "^0.3.11",
|
||||
"@zag-js/clipboard": "^0.47.0",
|
||||
"@zag-js/react": "^0.47.0",
|
||||
"@zag-js/tabs": "^0.74.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.5.1",
|
||||
"input-otp": "^1.2.4",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-json-view": "^1.21.3",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"smoldot": "^2.0.34",
|
||||
"sonner": "^1.7.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"vaul": "^0.9.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"packageManager": "pnpm@9.6.0+sha512.38dc6fba8dba35b39340b9700112c2fe1e12f10b17134715a4aa98ccf7bb035e76fd981cf0bb384dfa98f8d6af5481c2bef2f4266a24bfa20c34eb7147ce0b5e"
|
||||
}
|
53
playwright.config.ts
Normal file
53
playwright.config.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const isCI = !!process.env.CI
|
||||
|
||||
const dappUrl = isCI ? "http://localhost:4173" : "http://localhost:5173"
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: isCI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: dappUrl,
|
||||
permissions: ["clipboard-read"],
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "chrome",
|
||||
use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: isCI ? "pnpm preview --strictPort" : "pnpm dev --strictPort",
|
||||
cwd: "../demo",
|
||||
url: dappUrl,
|
||||
timeout: 120 * 1000,
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
})
|
15474
pnpm-lock.yaml
Normal file
15474
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
21
scripts/checkExtensionScriptSizes.js
Normal file
21
scripts/checkExtensionScriptSizes.js
Normal file
@ -0,0 +1,21 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const getFilePathsWithExtension = async (directory, extension, out = []) => {
|
||||
for (const file of await fs.readdir(directory)) {
|
||||
const filePath = path.join(directory, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
if (stats.isDirectory())
|
||||
await getFilePathsWithExtension(filePath, extension, out)
|
||||
else if (stats.isFile() && file.endsWith(extension)) out.push(filePath)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const MAX_SIZE = 1024 * 1024 * 4
|
||||
|
||||
for (const filePath of await getFilePathsWithExtension("dist", "js")) {
|
||||
const stats = await fs.stat(filePath)
|
||||
if (stats.size > MAX_SIZE)
|
||||
throw new Error(`${filePath} size is larger than 4MB`)
|
||||
}
|
21
scripts/generateManifest.js
Normal file
21
scripts/generateManifest.js
Normal file
@ -0,0 +1,21 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import url from "node:url"
|
||||
|
||||
const [src, dst] = process.argv.slice(2)
|
||||
|
||||
const manifest = JSON.parse(await fs.readFile(src, { encoding: "utf-8" }))
|
||||
const pkg = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.resolve(
|
||||
path.dirname(url.fileURLToPath(import.meta.url)),
|
||||
"../package.json",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
manifest.version = pkg.version
|
||||
|
||||
await fs.writeFile(dst, JSON.stringify(manifest, undefined, 2), {
|
||||
encoding: "utf8",
|
||||
})
|
367
src/background/createBackgroundRpc.ts
Normal file
367
src/background/createBackgroundRpc.ts
Normal file
@ -0,0 +1,367 @@
|
||||
import {
|
||||
type RpcMethodHandlers,
|
||||
type RpcMessage,
|
||||
type RpcMethodMiddleware,
|
||||
createRpc,
|
||||
RpcError,
|
||||
} from "@substrate/light-client-extension-helpers/utils"
|
||||
import { createTx } from "@substrate/light-client-extension-helpers/tx-helper"
|
||||
import { ss58Address, ss58Decode } from "@polkadot-labs/hdkd-helpers"
|
||||
import { toHex, fromHex } from "@polkadot-api/utils"
|
||||
import { getPolkadotSigner } from "@polkadot-api/signer"
|
||||
|
||||
import type { BackgroundRpcSpec } from "./types"
|
||||
import { createKeyring } from "./keyring"
|
||||
import { UserSignedExtensionName } from "../types/UserSignedExtension"
|
||||
import { createClient } from "@polkadot-api/substrate-client"
|
||||
import { getObservableClient } from "@polkadot-api/observable-client"
|
||||
import { filter, firstValueFrom, map, mergeMap, take } from "rxjs"
|
||||
import * as pjs from "./pjs"
|
||||
import { Bytes, Variant } from "@polkadot-api/substrate-bindings"
|
||||
import { InPageRpcSpec } from "../inpage/types"
|
||||
import { Context, InternalSignRequest } from "./rpc/types"
|
||||
import {
|
||||
CHANNEL_ID,
|
||||
wellKnownPrefixByGenesisHash,
|
||||
wellKnownDecimalsByGenesisHash
|
||||
} from "../constants"
|
||||
|
||||
import {
|
||||
addChainSpecHandler,
|
||||
listChainSpecsHandler,
|
||||
removeChainSpecHandler,
|
||||
} from "./rpc/chainspec"
|
||||
|
||||
const isUserSignedExtensionName = (s: string): s is UserSignedExtensionName => {
|
||||
return (
|
||||
s === "CheckMortality" ||
|
||||
s === "ChargeTransactionPayment" ||
|
||||
s === "ChargeAssetTxPayment"
|
||||
)
|
||||
}
|
||||
|
||||
const keyring = createKeyring()
|
||||
|
||||
let nextSignRequestId = 0
|
||||
|
||||
export const createBackgroundRpc = (
|
||||
sendMessage: (message: RpcMessage) => void
|
||||
) => {
|
||||
const getAccounts: RpcMethodHandlers<
|
||||
BackgroundRpcSpec,
|
||||
Context
|
||||
>["getAccounts"] = async ([chainId], { lightClientPageHelper }) => {
|
||||
const chains = await lightClientPageHelper.getChains()
|
||||
const chain = chains.find(({ genesisHash }) => genesisHash === chainId)
|
||||
if (!chain) throw new Error("unknown chain")
|
||||
return (await keyring.getAccounts(chain.genesisHash)).map(
|
||||
({ publicKey }) => ({
|
||||
address: ss58Address(
|
||||
publicKey,
|
||||
wellKnownPrefixByGenesisHash[chain.genesisHash]
|
||||
),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const notifyOnAccountsChanged = async (context: Context) =>
|
||||
context.notifyOnAccountsChanged(
|
||||
(
|
||||
await Promise.all(
|
||||
(await context.lightClientPageHelper.getChains()).map(
|
||||
({ genesisHash }) => getAccounts([genesisHash], context)
|
||||
),
|
||||
)
|
||||
).flatMap((accounts) => accounts),
|
||||
)
|
||||
|
||||
const handlers: RpcMethodHandlers<BackgroundRpcSpec, Context> = {
|
||||
getAccounts,
|
||||
async createTx(
|
||||
[chainId, from, callData],
|
||||
{ lightClientPageHelper, signRequests, port },
|
||||
) {
|
||||
const url = port.sender?.url
|
||||
if (!url) throw new Error("unknown url")
|
||||
const chains = await lightClientPageHelper.getChains()
|
||||
const chain = chains.find(({ genesisHash }) => genesisHash === chainId)
|
||||
if (!chain) throw new Error("unknown chain")
|
||||
|
||||
const id = nextSignRequestId++
|
||||
const client = getObservableClient(createClient(chain.provider))
|
||||
const chainHead$ = client.chainHead$()
|
||||
|
||||
const userSignedExtensionNames = await firstValueFrom(
|
||||
chainHead$.best$.pipe(
|
||||
mergeMap((blockInfo) =>
|
||||
chainHead$.getRuntimeContext$(blockInfo.hash).pipe(
|
||||
take(1),
|
||||
map(({ lookup: { metadata } }) =>
|
||||
metadata.extrinsic.signedExtensions
|
||||
.map(({ identifier }) => identifier)
|
||||
.filter(isUserSignedExtensionName)
|
||||
),
|
||||
),
|
||||
),
|
||||
filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
const signRequest = new Promise<
|
||||
Parameters<InternalSignRequest["resolve"]>[0]
|
||||
>(
|
||||
(resolve, reject) =>
|
||||
(signRequests[id] = {
|
||||
resolve,
|
||||
reject,
|
||||
chainId,
|
||||
url,
|
||||
address: ss58Address(
|
||||
from,
|
||||
wellKnownPrefixByGenesisHash[chain.genesisHash]
|
||||
),
|
||||
callData,
|
||||
userSignedExtensions: {
|
||||
type: "names",
|
||||
names: userSignedExtensionNames,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const window = await chrome.windows.create({
|
||||
focused: true,
|
||||
height: 640,
|
||||
width: 530,
|
||||
left: 0,
|
||||
top: 0,
|
||||
type: "popup",
|
||||
url: chrome.runtime.getURL(
|
||||
`ui/assets/wallet-popup.html#/sign-request/${id}`
|
||||
),
|
||||
})
|
||||
|
||||
const removeWindow = () => chrome.windows.remove(window.id!)
|
||||
port.onDisconnect.addListener(removeWindow)
|
||||
const onWindowsRemoved = (windowId: number) => {
|
||||
if (windowId !== window.id) return
|
||||
const signRequest = signRequests[id]
|
||||
if (!signRequest) return
|
||||
signRequest.reject()
|
||||
}
|
||||
chrome.windows.onRemoved.addListener(onWindowsRemoved)
|
||||
|
||||
try {
|
||||
const { userSignedExtensions } = await signRequest
|
||||
const [keypair, scheme] = await keyring.getKeypair(chainId, from)
|
||||
const signer = getPolkadotSigner(
|
||||
keypair.publicKey,
|
||||
scheme,
|
||||
keypair.sign
|
||||
)
|
||||
|
||||
const mortality = userSignedExtensions.CheckMortality ?? {
|
||||
mortal: true,
|
||||
period: 64,
|
||||
}
|
||||
const decimals = wellKnownDecimalsByGenesisHash[chainId] ?? 0
|
||||
const asset = userSignedExtensions.ChargeAssetTxPayment?.asset
|
||||
const tip =
|
||||
(asset
|
||||
? userSignedExtensions.ChargeAssetTxPayment?.tip
|
||||
: userSignedExtensions.ChargeTransactionPayment) ?? 0n
|
||||
|
||||
const tipWithDecimals = BigInt(tip * Math.pow(10, decimals))
|
||||
|
||||
const tx = await createTx(chain.provider)({
|
||||
signer,
|
||||
callData: fromHex(callData),
|
||||
hinted: {
|
||||
mortality,
|
||||
asset,
|
||||
tipWithDecimals,
|
||||
},
|
||||
})
|
||||
|
||||
return toHex(tx)
|
||||
} finally {
|
||||
delete signRequests[id]
|
||||
chrome.windows.remove(window.id!)
|
||||
port.onDisconnect.removeListener(removeWindow)
|
||||
chrome.windows.onRemoved.removeListener(onWindowsRemoved)
|
||||
}
|
||||
} finally {
|
||||
chainHead$.unfollow()
|
||||
client.destroy()
|
||||
}
|
||||
},
|
||||
async pjsSignPayload(
|
||||
[payload],
|
||||
{ port, lightClientPageHelper, signRequests }
|
||||
) {
|
||||
const url = port.sender?.url
|
||||
if (!url) throw new Error("unknown url")
|
||||
const chains = await lightClientPageHelper.getChains()
|
||||
const chain = chains.find(
|
||||
({ genesisHash }) => genesisHash === payload.genesisHash
|
||||
)
|
||||
if (!chain) throw new Error("unknown chain")
|
||||
const id = nextSignRequestId++
|
||||
const signRequest = new Promise<
|
||||
Parameters<InternalSignRequest["resolve"]>[0]
|
||||
>(
|
||||
(resolve, reject) =>
|
||||
(signRequests[id] = {
|
||||
resolve,
|
||||
reject,
|
||||
chainId: payload.genesisHash,
|
||||
url,
|
||||
address: payload.address,
|
||||
callData: payload.method,
|
||||
userSignedExtensions: {
|
||||
type: "values",
|
||||
values: pjs.getUserSignedExtensions(payload),
|
||||
},
|
||||
})
|
||||
)
|
||||
const window = await chrome.windows.create({
|
||||
focused: true,
|
||||
height: 640,
|
||||
width: 530,
|
||||
left: 0,
|
||||
top: 0,
|
||||
type: "popup",
|
||||
url: chrome.runtime.getURL(
|
||||
`ui/assets/wallet-popup.html#/sign-request/${id}`
|
||||
),
|
||||
})
|
||||
const removeWindow = () => chrome.windows.remove(window.id!)
|
||||
port.onDisconnect.addListener(removeWindow)
|
||||
const onWindowsRemoved = (windowId: number) => {
|
||||
if (windowId !== window.id) return
|
||||
const signRequest = signRequests[id]
|
||||
if (!signRequest) return
|
||||
signRequest.reject()
|
||||
}
|
||||
chrome.windows.onRemoved.addListener(onWindowsRemoved)
|
||||
try {
|
||||
await signRequest
|
||||
} finally {
|
||||
delete signRequests[id]
|
||||
chrome.windows.remove(window.id!)
|
||||
port.onDisconnect.removeListener(removeWindow)
|
||||
chrome.windows.onRemoved.removeListener(onWindowsRemoved)
|
||||
}
|
||||
const signaturePayload = await pjs.getSignaturePayload(
|
||||
chain.provider,
|
||||
payload
|
||||
)
|
||||
const multiSignatureEncoder = Variant({
|
||||
Ed25519: Bytes(64),
|
||||
Sr25519: Bytes(64),
|
||||
Ecdsa: Bytes(65),
|
||||
}).enc
|
||||
const [keypair, scheme] = await keyring.getKeypair(
|
||||
payload.genesisHash,
|
||||
toHex(ss58Decode(payload.address)[0])
|
||||
)
|
||||
return toHex(
|
||||
multiSignatureEncoder({
|
||||
type: scheme,
|
||||
value: keypair.sign(signaturePayload),
|
||||
})
|
||||
)
|
||||
},
|
||||
async getSignRequests(_, { signRequests }) {
|
||||
return signRequests
|
||||
},
|
||||
async approveSignRequest([id, userSignedExtensions], { signRequests }) {
|
||||
signRequests[id]?.resolve({
|
||||
userSignedExtensions,
|
||||
})
|
||||
},
|
||||
async cancelSignRequest([id], { signRequests }) {
|
||||
signRequests[id]?.reject()
|
||||
},
|
||||
async lockKeyring() {
|
||||
return keyring.lock()
|
||||
},
|
||||
async resetKeyring([password]) {
|
||||
return keyring.reset(password)
|
||||
},
|
||||
async unlockKeyring([password]) {
|
||||
return keyring.unlock(password)
|
||||
},
|
||||
async changePassword([currentPassword, newPassword]) {
|
||||
return keyring.changePassword(currentPassword, newPassword)
|
||||
},
|
||||
async createPassword([password]) {
|
||||
return keyring.setup(password)
|
||||
},
|
||||
async insertCryptoKey([args], context) {
|
||||
const existingKey = await keyring.getCryptoKey(args.name)
|
||||
|
||||
if (existingKey)
|
||||
throw new Error(`crypto key "${args.name}" already exists`)
|
||||
await keyring.insertCryptoKey(args)
|
||||
notifyOnAccountsChanged(context)
|
||||
},
|
||||
async updateCryptoKey([args], context) {
|
||||
const existingKey = await keyring.getCryptoKey(args.name)
|
||||
|
||||
if (!existingKey)
|
||||
throw new Error(`crypto key "${args.name}" does not exist`)
|
||||
await keyring.updateCryptoKey(args)
|
||||
notifyOnAccountsChanged(context)
|
||||
},
|
||||
async getCryptoKey([name]) {
|
||||
return keyring.getCryptoKey(name)
|
||||
},
|
||||
async getCryptoKeys() {
|
||||
return keyring.getCryptoKeys()
|
||||
},
|
||||
async revealCryptoKey([name, index]) {
|
||||
return keyring.revealCryptoKey(name, index)
|
||||
},
|
||||
async clearCryptoKeys([name], context) {
|
||||
await keyring.clearCryptoKeys(name)
|
||||
notifyOnAccountsChanged(context)
|
||||
},
|
||||
async clearCryptoKey([name, index], context) {
|
||||
await keyring.clearCryptoKey(name, index)
|
||||
notifyOnAccountsChanged(context)
|
||||
},
|
||||
async getKeyringState() {
|
||||
return {
|
||||
isLocked: await keyring.isLocked(),
|
||||
hasPassword: await keyring.hasPassword(),
|
||||
}
|
||||
},
|
||||
getChainSpecs: listChainSpecsHandler,
|
||||
addChainSpec: addChainSpecHandler,
|
||||
removeChainSpec: removeChainSpecHandler,
|
||||
}
|
||||
|
||||
type Method = keyof BackgroundRpcSpec
|
||||
const ALLOWED_WEB_METHODS: Method[] = [
|
||||
"createTx",
|
||||
"getAccounts",
|
||||
"pjsSignPayload",
|
||||
]
|
||||
const allowedMethodsMiddleware: RpcMethodMiddleware<Context> = async (
|
||||
next,
|
||||
request,
|
||||
context
|
||||
) => {
|
||||
const { port } = context
|
||||
if (
|
||||
port.name === CHANNEL_ID &&
|
||||
!ALLOWED_WEB_METHODS.includes(request.method as Method)
|
||||
)
|
||||
throw new RpcError("Method not found", -32601)
|
||||
return next(request, context)
|
||||
}
|
||||
return createRpc(sendMessage, handlers, [
|
||||
allowedMethodsMiddleware,
|
||||
]).withClient<InPageRpcSpec>()
|
||||
}
|
39
src/background/heartbeat.ts
Normal file
39
src/background/heartbeat.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tracks when a service worker was last alive and extends the service worker
|
||||
* lifetime by writing the current time to extension storage every 20 seconds.
|
||||
* You should still prepare for unexpected termination - for example, if the
|
||||
* extension process crashes or your extension is manually stopped at
|
||||
* chrome://serviceworker-internals.
|
||||
*
|
||||
* @link {https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers}
|
||||
*/
|
||||
let heartbeatInterval: NodeJS.Timeout | number = 0
|
||||
|
||||
async function runHeartbeat() {
|
||||
await chrome.storage.local.set({ "last-heartbeat": new Date().getTime() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the heartbeat interval which keeps the service worker alive. Call
|
||||
* this sparingly when you are doing work which requires persistence, and call
|
||||
* stopHeartbeat once that work is complete.
|
||||
*/
|
||||
export async function startHeartbeat() {
|
||||
// Run the heartbeat once at service worker startup.
|
||||
runHeartbeat().then(() => {
|
||||
// Then again every 20 seconds.
|
||||
heartbeatInterval = setInterval(runHeartbeat, 20 * 1000)
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopHeartbeat() {
|
||||
clearInterval(heartbeatInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last heartbeat stored in extension storage, or undefined if
|
||||
* the heartbeat has never run before.
|
||||
*/
|
||||
export async function getLastHeartbeat() {
|
||||
return (await chrome.storage.local.get("last-heartbeat"))["last-heartbeat"]
|
||||
}
|
118
src/background/index.ts
Normal file
118
src/background/index.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import {
|
||||
InputChain,
|
||||
register,
|
||||
} from "@substrate/light-client-extension-helpers/background"
|
||||
import { start } from "@substrate/light-client-extension-helpers/smoldot"
|
||||
import { createBackgroundRpc } from "./createBackgroundRpc"
|
||||
import * as storage from "./storage"
|
||||
import type { Account } from "./types"
|
||||
import { startHeartbeat } from "./heartbeat"
|
||||
import { CHANNEL_ID } from "../constants"
|
||||
|
||||
const { lightClientPageHelper, addOnAddChainByUserListener } = register({
|
||||
smoldotClient: start({ maxLogLevel: 4 }),
|
||||
getWellKnownChainSpecs: () =>
|
||||
// Note that this list doesn't necessarily always have to match the list of well-known
|
||||
// chains in `@substrate/connect`. The list of well-known chains is not part of the stability
|
||||
// guarantees of the connect <-> extension protocol and is thus allowed to change
|
||||
// between versions of the extension. For this reason, we don't use the `WellKnownChain`
|
||||
// enum from `@substrate/connect` but instead manually make the list in that enum match
|
||||
// the list present here.
|
||||
Promise.all(
|
||||
[
|
||||
"./chainspecs/casper_staging_testnet.json"
|
||||
].map((path) =>
|
||||
fetch(chrome.runtime.getURL(path)).then((response) => response.text()),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
const signRequests = {}
|
||||
|
||||
type BackgroundRpc = ReturnType<typeof createBackgroundRpc>
|
||||
const connectedRpcs: BackgroundRpc[] = []
|
||||
const notifyOnAccountsChanged = (accounts: Account[]) =>
|
||||
connectedRpcs.forEach((rpc) => rpc.notify("onAccountsChanged", [accounts]))
|
||||
const subscribeOnAccountsChanged = (rpc: BackgroundRpc) => {
|
||||
connectedRpcs.push(rpc)
|
||||
return () => {
|
||||
connectedRpcs.splice(connectedRpcs.indexOf(rpc), 1)
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onConnect.addListener((port) => {
|
||||
if (!port.name.startsWith(CHANNEL_ID)) return
|
||||
const rpc = createBackgroundRpc((msg) => port.postMessage(msg))
|
||||
port.onMessage.addListener((msg) =>
|
||||
rpc.handle(msg, {
|
||||
lightClientPageHelper,
|
||||
signRequests,
|
||||
port,
|
||||
notifyOnAccountsChanged,
|
||||
}),
|
||||
)
|
||||
|
||||
port.onDisconnect.addListener(subscribeOnAccountsChanged(rpc))
|
||||
})
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async ({ reason }) => {
|
||||
const self = await chrome.management.getSelf()
|
||||
|
||||
if (reason === chrome.runtime.OnInstalledReason.INSTALL) {
|
||||
const keystore = await storage.get("keystore")
|
||||
if (
|
||||
keystore ||
|
||||
// don't pop up a new tab in development so playwright can reliably
|
||||
// run tests on the extension
|
||||
self.installType === "development"
|
||||
)
|
||||
return
|
||||
chrome.tabs.create({
|
||||
url: chrome.runtime.getURL(`ui/assets/wallet-popup.html#/welcome`),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
addOnAddChainByUserListener(async (inputChain) => {
|
||||
const isRelayChain = !inputChain.relayChainGenesisHash
|
||||
const existingChain = await lightClientPageHelper.getChain(
|
||||
inputChain.genesisHash,
|
||||
)
|
||||
if (isRelayChain && !existingChain) {
|
||||
await waitForAddChainApproval(inputChain)
|
||||
|
||||
const persistedChain = await lightClientPageHelper.getChain(
|
||||
inputChain.genesisHash,
|
||||
)
|
||||
|
||||
if (!persistedChain) {
|
||||
throw new Error("User rejected")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const waitForAddChainApproval = async (inputChain: InputChain) => {
|
||||
const window = await chrome.windows.create({
|
||||
focused: true,
|
||||
width: 400,
|
||||
height: 600,
|
||||
left: 150,
|
||||
top: 150,
|
||||
type: "popup",
|
||||
url: chrome.runtime.getURL(
|
||||
`ui/assets/wallet-popup.html#/add-chain-by-user?params=${encodeURIComponent(JSON.stringify(inputChain))}`,
|
||||
),
|
||||
})
|
||||
const onWindowsRemoved = (windowId: number) => {
|
||||
if (windowId !== window.id) return
|
||||
resolveWindowClosed()
|
||||
}
|
||||
chrome.windows.onRemoved.addListener(onWindowsRemoved)
|
||||
|
||||
const { promise: windowClosedPromise, resolve: resolveWindowClosed } =
|
||||
Promise.withResolvers<void>()
|
||||
|
||||
await windowClosedPromise
|
||||
}
|
||||
|
||||
startHeartbeat()
|
357
src/background/keyring.ts
Normal file
357
src/background/keyring.ts
Normal file
@ -0,0 +1,357 @@
|
||||
import {
|
||||
type CreateDeriveFn,
|
||||
ecdsaCreateDerive,
|
||||
ed25519CreateDerive,
|
||||
sr25519CreateDerive,
|
||||
} from "@polkadot-labs/hdkd"
|
||||
import {
|
||||
sr25519,
|
||||
ed25519,
|
||||
ecdsa,
|
||||
KeyPair,
|
||||
Curve,
|
||||
} from "@polkadot-labs/hdkd-helpers"
|
||||
import { KeystoreMeta, keystoreV4, type KeystoreV4WithMeta } from "./keystore"
|
||||
import { assert } from "./utils"
|
||||
import * as storage from "./storage"
|
||||
import {
|
||||
InsertCryptoKeyArgs,
|
||||
UpdateCryptoKeyArgs,
|
||||
RemoveCryptoKeyArgs,
|
||||
KeystoreAccount,
|
||||
RevealCryptoKeyArgs,
|
||||
} from "./types"
|
||||
import { toHex, fromHex } from "@polkadot-api/utils"
|
||||
import { wellKnownChainIdByGenesisHash } from "../constants"
|
||||
|
||||
const createDeriveFnMap: Record<string, CreateDeriveFn> = {
|
||||
Sr25519: sr25519CreateDerive,
|
||||
Ed25519: ed25519CreateDerive,
|
||||
Ecdsa: ecdsaCreateDerive,
|
||||
}
|
||||
|
||||
const curveFnMap: Record<string, Curve> = {
|
||||
Sr25519: sr25519,
|
||||
Ed25519: ed25519,
|
||||
Ecdsa: ecdsa,
|
||||
}
|
||||
|
||||
const createKeyPair = (privateKey: string, scheme: string): KeyPair => {
|
||||
const curve = curveFnMap[scheme]
|
||||
if (!curve) throw new Error("unsupported signature scheme")
|
||||
|
||||
return {
|
||||
publicKey: curve.getPublicKey(privateKey),
|
||||
sign(message) {
|
||||
return curve.sign(message, privateKey)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const createKeyring = () => {
|
||||
const getKeystore = () => storage.get("keystore")
|
||||
const setKeystore = (keystore: KeystoreV4WithMeta) =>
|
||||
storage.set("keystore", keystore)
|
||||
const removeKeystore = () => storage.remove("keystore")
|
||||
|
||||
const getKeystoreAccounts = (
|
||||
keystoreMeta: KeystoreMeta,
|
||||
): KeystoreAccount[] => {
|
||||
switch (keystoreMeta.type) {
|
||||
case "KeysetKeystore":
|
||||
return keystoreMeta.derivationPaths.map((d) => ({
|
||||
...d,
|
||||
type: "Keyset",
|
||||
}))
|
||||
case "KeypairKeystore":
|
||||
return [
|
||||
{
|
||||
type: "Keypair",
|
||||
publicKey: keystoreMeta.publicKey,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const getCryptoKeys = async () => {
|
||||
const keys = (await getKeystore())?.meta ?? []
|
||||
|
||||
return keys.map((meta) => ({
|
||||
name: meta.name,
|
||||
scheme: meta.scheme,
|
||||
accounts: getKeystoreAccounts(meta),
|
||||
createdAt: meta.createdAt,
|
||||
}))
|
||||
}
|
||||
|
||||
const decodeSecrets = (secrets: Uint8Array) =>
|
||||
JSON.parse(new TextDecoder().decode(secrets)) as string[]
|
||||
const encodeSecrets = (secrets: string[]) =>
|
||||
new TextEncoder().encode(JSON.stringify(secrets))
|
||||
|
||||
const getAccounts = async (chainId: string) => {
|
||||
const keystore = await getKeystore()
|
||||
if (!keystore) return []
|
||||
|
||||
return keystore.meta
|
||||
.flatMap(getKeystoreAccounts)
|
||||
.filter(
|
||||
(account) =>
|
||||
(account.type === "Keyset" && account.chainId === chainId) ||
|
||||
account.type !== "Keyset",
|
||||
)
|
||||
}
|
||||
|
||||
const insertCryptoKey = async (args: InsertCryptoKeyArgs) => {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
assert(
|
||||
["Sr25519", "Ed25519", "Ecdsa"].includes(args.scheme),
|
||||
"invalid signature scheme",
|
||||
)
|
||||
|
||||
const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword))
|
||||
const secret = args.type === "Keyset" ? args.miniSecret : args.privatekey
|
||||
const newKeystore = keystoreV4.create(
|
||||
currentPassword,
|
||||
encodeSecrets([...secrets, secret]),
|
||||
)
|
||||
|
||||
const newCryptoKey =
|
||||
args.type === "Keyset"
|
||||
? {
|
||||
type: "KeysetKeystore" as const,
|
||||
derivationPaths: args.derivationPaths,
|
||||
}
|
||||
: args.type === "Keypair"
|
||||
? {
|
||||
type: "KeypairKeystore" as const,
|
||||
publicKey: toHex(
|
||||
createKeyPair(args.privatekey, args.scheme).publicKey,
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
if (!newCryptoKey) throw new Error("invalid keystore type")
|
||||
|
||||
setKeystore({
|
||||
...newKeystore,
|
||||
meta: [
|
||||
...keystore.meta,
|
||||
{
|
||||
name: args.name,
|
||||
scheme: args.scheme,
|
||||
createdAt: args.createdAt,
|
||||
...newCryptoKey,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const updateCryptoKey = async (args: UpdateCryptoKeyArgs) => {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
const cryptoKeyIndex = keystore.meta.findIndex((k) => k.name === args.name)
|
||||
assert(cryptoKeyIndex != -1, "keyring name not found")
|
||||
|
||||
const uniqueNetworks = new Map()
|
||||
keystore.meta[cryptoKeyIndex].derivationPaths.forEach((obj: any) =>
|
||||
uniqueNetworks.set(wellKnownChainIdByGenesisHash[obj.chainId], {
|
||||
network: wellKnownChainIdByGenesisHash[obj.chainId],
|
||||
chainId: obj.chainId,
|
||||
})
|
||||
)
|
||||
|
||||
const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword))
|
||||
const derive = createDeriveFnMap[keystore.meta[cryptoKeyIndex].scheme](secrets[cryptoKeyIndex])
|
||||
const newDerivationPaths = Array.from(uniqueNetworks.values())
|
||||
.map(({ network, chainId }) => {
|
||||
return {
|
||||
chainId,
|
||||
path: `//${network}/${args.deviation}`,
|
||||
publicKey: toHex(derive(`//${network}/${args.deviation}`).publicKey),
|
||||
}
|
||||
})
|
||||
|
||||
keystore.meta[cryptoKeyIndex].derivationPaths.push(...newDerivationPaths)
|
||||
setKeystore(keystore)
|
||||
}
|
||||
|
||||
const clearCryptoKey = async (name: string, index: number) => {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
const cryptoKeyIndex = keystore.meta.findIndex(obj => obj.name === name)
|
||||
assert(cryptoKeyIndex != -1, "keyring name not found")
|
||||
const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword))
|
||||
switch (keystore.meta.at(cryptoKeyIndex)?.type) {
|
||||
case "KeysetKeystore":
|
||||
keystore.meta.at(cryptoKeyIndex)?.derivationPaths.splice(index, 1)
|
||||
setKeystore(keystore)
|
||||
break
|
||||
case "KeypairKeystore":
|
||||
secrets.splice(cryptoKeyIndex, 1)
|
||||
keystore.meta.splice(cryptoKeyIndex, 1)
|
||||
const newKeystore = keystoreV4.create(
|
||||
currentPassword,
|
||||
encodeSecrets(secrets),
|
||||
)
|
||||
setKeystore({
|
||||
...newKeystore,
|
||||
meta: keystore.meta,
|
||||
})
|
||||
break
|
||||
default:
|
||||
throw new Error("invalid keystore type")
|
||||
}
|
||||
}
|
||||
|
||||
const clearCryptoKeys = async (args: RemoveCryptoKeyArgs) => {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
const cryptoKeyIndex = keystore.meta.findIndex(obj => obj.name === args.name)
|
||||
assert(cryptoKeyIndex != -1, "keyring name not found")
|
||||
const secrets = decodeSecrets(keystoreV4.decrypt(keystore, currentPassword))
|
||||
secrets.splice(cryptoKeyIndex, 1)
|
||||
const newKeystore = keystoreV4.create(
|
||||
currentPassword,
|
||||
encodeSecrets(secrets),
|
||||
)
|
||||
keystore.meta.splice(cryptoKeyIndex, 1)
|
||||
setKeystore({
|
||||
...newKeystore,
|
||||
meta: keystore.meta,
|
||||
})
|
||||
}
|
||||
|
||||
const revealCryptoKey = async (name: string, index: number) => {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
const cryptoKeyIndex = keystore.meta.findIndex((k) => k.name === name)
|
||||
assert(cryptoKeyIndex != -1, "keyring name not found")
|
||||
const secret = decodeSecrets(
|
||||
keystoreV4.decrypt(keystore, currentPassword)
|
||||
).at(cryptoKeyIndex)
|
||||
assert(secret, "secret key should exists")
|
||||
// TODO: need to get secret from secret and derivation path
|
||||
return `0x${secret}`
|
||||
}
|
||||
|
||||
let currentPassword: string | undefined
|
||||
|
||||
return {
|
||||
async unlock(password: string) {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
if (!keystoreV4.verifyPassword(keystore, password))
|
||||
throw new Error("invalid password")
|
||||
currentPassword = password
|
||||
},
|
||||
async lock() {
|
||||
assert(await getKeystore(), "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
currentPassword = undefined
|
||||
},
|
||||
async isLocked() {
|
||||
return !currentPassword || !(await getKeystore())
|
||||
},
|
||||
async reset(password: string) {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
if (!keystoreV4.verifyPassword(keystore, password))
|
||||
throw new Error("invalid password")
|
||||
currentPassword = undefined
|
||||
await removeKeystore()
|
||||
},
|
||||
async changePassword(password: string, newPassword: string) {
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
if (!keystoreV4.verifyPassword(keystore, password))
|
||||
throw new Error("invalid password")
|
||||
currentPassword = newPassword
|
||||
await setKeystore({
|
||||
...keystoreV4.create(
|
||||
newPassword,
|
||||
keystoreV4.decrypt(keystore, password),
|
||||
),
|
||||
meta: keystore.meta,
|
||||
})
|
||||
},
|
||||
async setup(password: string) {
|
||||
assert(!(await getKeystore()), "keyring is already setup")
|
||||
await setKeystore({
|
||||
...keystoreV4.create(
|
||||
password,
|
||||
new TextEncoder().encode(JSON.stringify([])),
|
||||
),
|
||||
meta: [],
|
||||
})
|
||||
currentPassword = password
|
||||
},
|
||||
async hasPassword() {
|
||||
return !!(await getKeystore())
|
||||
},
|
||||
getAccounts,
|
||||
insertCryptoKey,
|
||||
updateCryptoKey,
|
||||
getCryptoKeys,
|
||||
async getCryptoKey(name: string) {
|
||||
return (await getCryptoKeys())?.find((m) => m.name === name)
|
||||
},
|
||||
revealCryptoKey,
|
||||
clearCryptoKey,
|
||||
clearCryptoKeys,
|
||||
async getKeypair(chainId: string, publicKey: string) {
|
||||
assert(currentPassword, "keyring must be unlocked")
|
||||
const keystore = await getKeystore()
|
||||
assert(keystore, "keyring must be setup")
|
||||
|
||||
const keysetIndex = keystore.meta.findIndex((keyset) => {
|
||||
switch (keyset.type) {
|
||||
case "KeysetKeystore":
|
||||
return keyset.derivationPaths.some(
|
||||
(d) => d.chainId === chainId && d.publicKey === publicKey,
|
||||
)
|
||||
case "KeypairKeystore":
|
||||
return keyset.publicKey === publicKey
|
||||
default:
|
||||
throw new Error("invalid keystore type")
|
||||
}
|
||||
})
|
||||
|
||||
if (keysetIndex === -1) {
|
||||
throw new Error("unknown account")
|
||||
}
|
||||
|
||||
const secret = decodeSecrets(
|
||||
keystoreV4.decrypt(keystore, currentPassword),
|
||||
)[keysetIndex]
|
||||
|
||||
const cryptoKey = keystore.meta[keysetIndex]
|
||||
switch (cryptoKey.type) {
|
||||
case "KeysetKeystore": {
|
||||
const { derivationPaths, scheme } = cryptoKey
|
||||
const derivationPath = derivationPaths.find(
|
||||
(d) => d.publicKey === publicKey,
|
||||
)!
|
||||
const createDeriveFn = createDeriveFnMap[scheme]
|
||||
if (!createDeriveFn) throw new Error("invalid signature scheme")
|
||||
return [
|
||||
createDeriveFn(secret)(derivationPath.path),
|
||||
scheme as "Sr25519" | "Ed25519" | "Ecdsa",
|
||||
] as const
|
||||
}
|
||||
case "KeypairKeystore": {
|
||||
let keypair = createKeyPair(secret, cryptoKey.scheme)
|
||||
|
||||
return [keypair, cryptoKey.scheme] as const
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
3
src/background/keystore/index.ts
Normal file
3
src/background/keystore/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * as keystoreV4 from "./keystoreV4"
|
||||
export type * from "./keystoreV4"
|
||||
export type * from "./types"
|
114
src/background/keystore/keystoreV4.test.ts
Normal file
114
src/background/keystore/keystoreV4.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { expect, it, describe } from "vitest"
|
||||
|
||||
import { type KeystoreV4, verifyPassword, decrypt, create } from "./keystoreV4"
|
||||
import { hexToBytes, randomBytes } from "@noble/hashes/utils"
|
||||
|
||||
type TestVector = {
|
||||
password: string
|
||||
encodedPassword: string
|
||||
secret: string
|
||||
keystoreJson: KeystoreV4
|
||||
}
|
||||
// From https://eips.ethereum.org/EIPS/eip-2335#test-cases
|
||||
const testVectors: TestVector[] = [
|
||||
{
|
||||
password: "𝔱𝔢𝔰𝔱𝔭𝔞𝔰𝔰𝔴𝔬𝔯𝔡🔑",
|
||||
encodedPassword: "7465737470617373776f7264f09f9491",
|
||||
secret: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
||||
keystoreJson: {
|
||||
crypto: {
|
||||
kdf: {
|
||||
function: "scrypt",
|
||||
params: {
|
||||
dklen: 32,
|
||||
n: 262144,
|
||||
p: 1,
|
||||
r: 8,
|
||||
salt: "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3",
|
||||
},
|
||||
message: "",
|
||||
},
|
||||
checksum: {
|
||||
function: "sha256",
|
||||
params: {},
|
||||
message: "d2217fe5f3e9a1e34581ef8a78f7c9928e436d36dacc5e846690a5581e8ea484",
|
||||
},
|
||||
cipher: {
|
||||
function: "aes-128-ctr",
|
||||
params: {
|
||||
iv: "264daa3f303d7259501c93d997d84fe6",
|
||||
},
|
||||
message: "06ae90d55fe0a6e9c5c3bc5b170827b2e5cce3929ed3f116c2811e6366dfe20f",
|
||||
},
|
||||
},
|
||||
description: "This is a test keystore that uses scrypt to secure the secret.",
|
||||
pubkey: "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07",
|
||||
path: "m/12381/60/3141592653/589793238",
|
||||
uuid: "1d85ae20-35c5-4611-98e8-aa14a633906f",
|
||||
version: 4,
|
||||
} as KeystoreV4,
|
||||
},
|
||||
{
|
||||
password: "𝔱𝔢𝔰𝔱𝔭𝔞𝔰𝔰𝔴𝔬𝔯𝔡🔑",
|
||||
encodedPassword: "7465737470617373776f7264f09f9491",
|
||||
secret: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f",
|
||||
keystoreJson: {
|
||||
crypto: {
|
||||
kdf: {
|
||||
function: "pbkdf2",
|
||||
params: {
|
||||
dklen: 32,
|
||||
c: 262144,
|
||||
prf: "hmac-sha256",
|
||||
salt: "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3",
|
||||
},
|
||||
message: "",
|
||||
},
|
||||
checksum: {
|
||||
function: "sha256",
|
||||
params: {},
|
||||
message: "8a9f5d9912ed7e75ea794bc5a89bca5f193721d30868ade6f73043c6ea6febf1",
|
||||
},
|
||||
cipher: {
|
||||
function: "aes-128-ctr",
|
||||
params: {
|
||||
iv: "264daa3f303d7259501c93d997d84fe6",
|
||||
},
|
||||
message: "cee03fde2af33149775b7223e7845e4fb2c8ae1792e5f99fe9ecf474cc8c16ad",
|
||||
},
|
||||
},
|
||||
description: "This is a test keystore that uses PBKDF2 to secure the secret.",
|
||||
pubkey: "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07",
|
||||
path: "m/12381/60/0/0",
|
||||
uuid: "64625def-3331-4eea-ab6f-782f3ed16a83",
|
||||
version: 4,
|
||||
} as KeystoreV4,
|
||||
},
|
||||
]
|
||||
|
||||
describe.each(testVectors)("test vector", (testVector) => {
|
||||
it("should verifyPassword", () => {
|
||||
expect(verifyPassword(testVector.keystoreJson, testVector.password)).toBe(
|
||||
true,
|
||||
)
|
||||
expect(verifyPassword(testVector.keystoreJson, "invalid password")).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it("should decrypt", () => {
|
||||
expect(decrypt(testVector.keystoreJson, testVector.password)).toStrictEqual(
|
||||
hexToBytes(testVector.secret),
|
||||
)
|
||||
expect(() => decrypt(testVector.keystoreJson, "invalid password")).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
it("should create keystore", () => {
|
||||
const secret = randomBytes(32)
|
||||
const keystore = create("123456", secret)
|
||||
expect(verifyPassword(keystore, "123456")).toBe(true)
|
||||
expect(verifyPassword(keystore, "invalid password")).toBe(false)
|
||||
expect(decrypt(keystore, "123456")).toStrictEqual(secret)
|
||||
expect(() => decrypt(keystore, "invalid password")).toThrow()
|
||||
})
|
195
src/background/keystore/keystoreV4.ts
Normal file
195
src/background/keystore/keystoreV4.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { scrypt } from "@noble/hashes/scrypt"
|
||||
import { pbkdf2 } from "@noble/hashes/pbkdf2"
|
||||
import { sha256 } from "@noble/hashes/sha256"
|
||||
import { ctr as aesCtr } from "@noble/ciphers/aes"
|
||||
import {
|
||||
bytesToHex,
|
||||
concatBytes,
|
||||
hexToBytes,
|
||||
randomBytes,
|
||||
} from "@noble/hashes/utils"
|
||||
import { crypto } from "@noble/hashes/crypto"
|
||||
import { managedNonce } from "@noble/ciphers/webcrypto"
|
||||
import { xsalsa20poly1305 } from "@noble/ciphers/salsa"
|
||||
|
||||
export type Cipher = {
|
||||
encrypt(plaintext: Uint8Array): Uint8Array
|
||||
decrypt(ciphertext: Uint8Array): Uint8Array
|
||||
}
|
||||
|
||||
export type KeystoreV4 = {
|
||||
version: 4
|
||||
uuid: string
|
||||
description?: string
|
||||
crypto: {
|
||||
kdf: KdfModule
|
||||
checksum: ChecksumModule
|
||||
cipher: CipherModule
|
||||
}
|
||||
}
|
||||
|
||||
type KdfModule = ScryptKdfModule | Pbkdf2KdfModule
|
||||
|
||||
type ScryptKdfModule = {
|
||||
function: "scrypt"
|
||||
params: {
|
||||
dklen: number
|
||||
n: number
|
||||
p: number
|
||||
r: number
|
||||
salt: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
|
||||
type Pbkdf2KdfModule = {
|
||||
function: "pbkdf2"
|
||||
params: {
|
||||
dklen: number
|
||||
c: number
|
||||
prf: string
|
||||
salt: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
|
||||
type ChecksumModule = {
|
||||
function: "sha256"
|
||||
params: {}
|
||||
message: string
|
||||
}
|
||||
|
||||
type CipherModule = Aes128CtrCipherModule | Xsalsa20Poly1305CipherModule
|
||||
|
||||
type Aes128CtrCipherModule = {
|
||||
function: "aes-128-ctr"
|
||||
params: {
|
||||
iv: string
|
||||
}
|
||||
message: string
|
||||
}
|
||||
|
||||
type Xsalsa20Poly1305CipherModule = {
|
||||
function: "xsalsa20-poly1305"
|
||||
params: {}
|
||||
message: string
|
||||
}
|
||||
|
||||
export const create = (password: string, secret: Uint8Array): KeystoreV4 => {
|
||||
const kdf = {
|
||||
function: "scrypt" as const,
|
||||
params: {
|
||||
dklen: 32,
|
||||
n: 2 ** 16,
|
||||
r: 8,
|
||||
p: 1,
|
||||
salt: bytesToHex(randomBytes(32)),
|
||||
},
|
||||
message: "",
|
||||
}
|
||||
const key = deriveKey(kdf, password)
|
||||
const ciphertext = getCipher_(
|
||||
{
|
||||
function: "xsalsa20-poly1305",
|
||||
params: {},
|
||||
message: "",
|
||||
},
|
||||
key,
|
||||
).encrypt(secret)
|
||||
return {
|
||||
version: 4,
|
||||
uuid: crypto.randomUUID(),
|
||||
crypto: {
|
||||
kdf,
|
||||
checksum: {
|
||||
function: "sha256",
|
||||
params: {},
|
||||
message: bytesToHex(computeChecksum(key.slice(16, 32), ciphertext)),
|
||||
},
|
||||
cipher: {
|
||||
function: "xsalsa20-poly1305",
|
||||
params: {},
|
||||
message: bytesToHex(ciphertext),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const controlCodeFilter = (charCode: number) =>
|
||||
charCode > 0x1f && !(charCode >= 0x7f && charCode <= 0x9f)
|
||||
|
||||
const encodePassword = (password: string) =>
|
||||
new TextEncoder().encode(
|
||||
password
|
||||
.normalize("NFKD")
|
||||
.split("")
|
||||
.filter((char) => controlCodeFilter(char.charCodeAt(0)))
|
||||
.join(""),
|
||||
)
|
||||
|
||||
const deriveKey = (kdf: KdfModule, password: string) => {
|
||||
if (kdf.function === "scrypt") {
|
||||
const { salt, dklen: dkLen, n: N, p, r } = kdf.params
|
||||
return scrypt(encodePassword(password), hexToBytes(salt), {
|
||||
dkLen,
|
||||
N,
|
||||
p,
|
||||
r,
|
||||
})
|
||||
} else if (kdf.function === "pbkdf2") {
|
||||
const { salt, prf, dklen: dkLen, c } = kdf.params
|
||||
if (prf !== "hmac-sha256") throw new Error("Invalid prf")
|
||||
return pbkdf2(sha256, encodePassword(password), hexToBytes(salt), {
|
||||
dkLen,
|
||||
c,
|
||||
})
|
||||
}
|
||||
throw new Error("Invalid key derivation function")
|
||||
}
|
||||
|
||||
const computeChecksum = (key: Uint8Array, ciphertext: Uint8Array) =>
|
||||
sha256(concatBytes(key, ciphertext))
|
||||
|
||||
const verifyChecksum = (
|
||||
checksum: ChecksumModule,
|
||||
key: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
) => {
|
||||
if (checksum.function !== "sha256")
|
||||
throw new Error("Invalid checksum function")
|
||||
return checksum.message === bytesToHex(computeChecksum(key, ciphertext))
|
||||
}
|
||||
|
||||
export const verifyPassword = (
|
||||
{ crypto: { kdf, cipher, checksum } }: KeystoreV4,
|
||||
password: string,
|
||||
) => {
|
||||
const decryptionKey = deriveKey(kdf, password)
|
||||
const ciphertext = hexToBytes(cipher.message)
|
||||
return verifyChecksum(checksum, decryptionKey.slice(16, 32), ciphertext)
|
||||
}
|
||||
|
||||
export const decrypt = (keystore: KeystoreV4, password: string) => {
|
||||
const ciphertext = hexToBytes(keystore.crypto.cipher.message)
|
||||
return getCipher(keystore, password).decrypt(ciphertext)
|
||||
}
|
||||
|
||||
const getCipher_ = (cipher: CipherModule, key: Uint8Array) => {
|
||||
if (cipher.function === "xsalsa20-poly1305")
|
||||
return managedNonce(xsalsa20poly1305)(key.slice(0, 32))
|
||||
else if (cipher.function === "aes-128-ctr")
|
||||
return aesCtr(key.slice(0, 16), hexToBytes(cipher.params.iv))
|
||||
|
||||
throw new Error("Invalid cipher function")
|
||||
}
|
||||
|
||||
export const getCipher = (
|
||||
{ crypto: { kdf, checksum, cipher } }: KeystoreV4,
|
||||
password: string,
|
||||
): Cipher => {
|
||||
const ciphertext = hexToBytes(cipher.message)
|
||||
const key = deriveKey(kdf, password)
|
||||
if (!verifyChecksum(checksum, key.slice(16, 32), ciphertext))
|
||||
throw new Error("Invalid password")
|
||||
return getCipher_(cipher, key)
|
||||
}
|
29
src/background/keystore/types.ts
Normal file
29
src/background/keystore/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { KeystoreV4 } from "./keystoreV4"
|
||||
|
||||
export type DerivationPath = {
|
||||
chainId: string
|
||||
path: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export type BaseKeystore = {
|
||||
name: string
|
||||
scheme: "Sr25519" | "Ed25519" | "Ecdsa"
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type KeysetKeystore = BaseKeystore & {
|
||||
type: "KeysetKeystore"
|
||||
derivationPaths: DerivationPath[]
|
||||
}
|
||||
|
||||
export type KeypairKeystore = BaseKeystore & {
|
||||
type: "KeypairKeystore"
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export type KeystoreMeta = KeysetKeystore | KeypairKeystore
|
||||
|
||||
export type KeystoreV4WithMeta = KeystoreV4 & {
|
||||
meta: KeystoreMeta[]
|
||||
}
|
117
src/background/pjs.ts
Normal file
117
src/background/pjs.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { createClient } from "@polkadot-api/substrate-client"
|
||||
import { getObservableClient } from "@polkadot-api/observable-client"
|
||||
import { getDynamicBuilder, getLookupFn } from "@polkadot-api/metadata-builders"
|
||||
import {
|
||||
Bytes,
|
||||
Struct,
|
||||
_void,
|
||||
compact,
|
||||
u32,
|
||||
Option,
|
||||
u16,
|
||||
} from "@polkadot-api/substrate-bindings"
|
||||
import { fromHex, mergeUint8 } from "@polkadot-api/utils"
|
||||
import { filter, firstValueFrom } from "rxjs"
|
||||
import { blake2b256 } from "@polkadot-labs/hdkd-helpers"
|
||||
import type { Pjs } from "./types"
|
||||
import { UserSignedExtensions } from "../types/UserSignedExtension"
|
||||
import type { JsonRpcProvider } from "@polkadot-api/json-rpc-provider"
|
||||
|
||||
export const getSignaturePayload = async (
|
||||
provider: JsonRpcProvider,
|
||||
payload: Pjs.SignerPayloadJSON,
|
||||
) => {
|
||||
const { metadata$, unfollow } = getObservableClient(
|
||||
createClient(provider),
|
||||
).chainHead$()
|
||||
const metadata = await firstValueFrom(
|
||||
metadata$.pipe(filter(Boolean)),
|
||||
).finally(unfollow)
|
||||
const dynamicBuilder = getDynamicBuilder(getLookupFn(metadata))
|
||||
const [extra, additionalSigned] = metadata.extrinsic.signedExtensions.reduce<
|
||||
[extra: Uint8Array[], additionalSigned: Uint8Array[]]
|
||||
>(
|
||||
(
|
||||
[extra, additionalSigned],
|
||||
{ identifier, type: extraTy, additionalSigned: additionalSignedTy },
|
||||
) => {
|
||||
switch (identifier) {
|
||||
case "CheckSpecVersion": {
|
||||
additionalSigned.push(u32.enc(Number(payload.specVersion)))
|
||||
break
|
||||
}
|
||||
case "CheckTxVersion": {
|
||||
additionalSigned.push(u32.enc(Number(payload.transactionVersion)))
|
||||
break
|
||||
}
|
||||
case "CheckGenesis": {
|
||||
additionalSigned.push(fromHex(payload.genesisHash))
|
||||
break
|
||||
}
|
||||
case "CheckMortality": {
|
||||
extra.push(fromHex(payload.era))
|
||||
additionalSigned.push(fromHex(payload.blockHash))
|
||||
break
|
||||
}
|
||||
case "CheckNonce": {
|
||||
extra.push(compact.enc(Number(payload.nonce)))
|
||||
break
|
||||
}
|
||||
case "ChargeTransactionPayment": {
|
||||
extra.push(compact.enc(BigInt(payload.tip)))
|
||||
break
|
||||
}
|
||||
case "ChargeAssetTxPayment": {
|
||||
extra.push(
|
||||
Struct({
|
||||
tip: compact,
|
||||
asset: Option(Bytes(Infinity)),
|
||||
}).enc({
|
||||
tip: BigInt(payload.tip),
|
||||
// TODO: update when PJS adds support
|
||||
asset: undefined,
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (
|
||||
dynamicBuilder.buildDefinition(extraTy) === _void &&
|
||||
dynamicBuilder.buildDefinition(additionalSignedTy) === _void
|
||||
)
|
||||
break
|
||||
throw new Error(`unsupported signed-extension: ${identifier}`)
|
||||
}
|
||||
}
|
||||
return [extra, additionalSigned]
|
||||
},
|
||||
[[], []],
|
||||
)
|
||||
const signaturePayload = mergeUint8(
|
||||
fromHex(payload.method),
|
||||
...extra,
|
||||
...additionalSigned,
|
||||
)
|
||||
return signaturePayload.length > 256
|
||||
? blake2b256(signaturePayload)
|
||||
: signaturePayload
|
||||
}
|
||||
|
||||
export const getUserSignedExtensions = (payload: Pjs.SignerPayloadJSON) => {
|
||||
const userSignedExtensions: Partial<UserSignedExtensions> = {}
|
||||
const mortality = fromHex(payload.era)
|
||||
userSignedExtensions.CheckMortality =
|
||||
// Ser mortality encoding https://spec.polkadot.network/id-extrinsics#sect-mortality-encoding
|
||||
mortality.length === 1
|
||||
? { mortal: false }
|
||||
: { mortal: true, period: 2 << u16.dec(mortality) % (1 << 4) }
|
||||
if (payload.signedExtensions.includes("ChargeTransactionPayment"))
|
||||
userSignedExtensions.ChargeTransactionPayment = Number(payload.tip)
|
||||
else if (payload.signedExtensions.includes("ChargeAssetTxPayment"))
|
||||
userSignedExtensions.ChargeAssetTxPayment = {
|
||||
// @ts-expect-error FIXME: bigint needs to be serialized
|
||||
tip: Number(payload.tip),
|
||||
}
|
||||
|
||||
return userSignedExtensions
|
||||
}
|
68
src/background/rpc/chainspec.ts
Normal file
68
src/background/rpc/chainspec.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { RpcMethodHandlers } from "@substrate/light-client-extension-helpers/utils"
|
||||
import { z } from "zod"
|
||||
|
||||
import { BackgroundRpcSpec } from "../types"
|
||||
import { wellKnownGenesisHashByChainId } from "../../constants"
|
||||
import { Context } from "./types"
|
||||
|
||||
const chainSpecSchema = z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
relay_chain: z.string().optional(),
|
||||
})
|
||||
|
||||
export const listChainSpecsHandler: RpcMethodHandlers<
|
||||
BackgroundRpcSpec,
|
||||
Context
|
||||
>["getChainSpecs"] = async (_, { lightClientPageHelper }) => {
|
||||
const chains = await lightClientPageHelper.getChains()
|
||||
const chainSpecs = chains.map((chain) => {
|
||||
const parsed = chainSpecSchema.parse(JSON.parse(chain.chainSpec))
|
||||
return {
|
||||
...parsed,
|
||||
genesisHash: chain.genesisHash,
|
||||
isWellKnown: !!wellKnownGenesisHashByChainId[parsed.id],
|
||||
raw: chain.chainSpec,
|
||||
}
|
||||
})
|
||||
|
||||
return chainSpecs
|
||||
}
|
||||
|
||||
export const addChainSpecHandler: RpcMethodHandlers<
|
||||
BackgroundRpcSpec,
|
||||
Context
|
||||
>["addChainSpec"] = async ([chainSpec], { lightClientPageHelper }) => {
|
||||
const chainSpecParsed = chainSpecSchema.parse(JSON.parse(chainSpec))
|
||||
|
||||
const relayChainChainSpec = await lightClientPageHelper
|
||||
.getChains()
|
||||
.then((chains) =>
|
||||
chains.map((chain) => ({
|
||||
...chainSpecSchema.parse(JSON.parse(chain.chainSpec)),
|
||||
genesisHash: chain.genesisHash,
|
||||
})),
|
||||
)
|
||||
.then((chains) =>
|
||||
chains.find((chain) => chain.id === chainSpecParsed.relay_chain),
|
||||
)
|
||||
|
||||
if (chainSpecParsed.relay_chain && !relayChainChainSpec) {
|
||||
throw new Error("relay chain not found")
|
||||
}
|
||||
if (relayChainChainSpec?.relay_chain) {
|
||||
throw new Error("relay chain cannot be a parachain")
|
||||
}
|
||||
|
||||
await lightClientPageHelper.persistChain(
|
||||
chainSpec,
|
||||
relayChainChainSpec?.genesisHash,
|
||||
)
|
||||
}
|
||||
|
||||
export const removeChainSpecHandler: RpcMethodHandlers<
|
||||
BackgroundRpcSpec,
|
||||
Context
|
||||
>["removeChainSpec"] = async ([genesisHash], { lightClientPageHelper }) => {
|
||||
await lightClientPageHelper.deleteChain(genesisHash)
|
||||
}
|
19
src/background/rpc/types.ts
Normal file
19
src/background/rpc/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { LightClientPageHelper } from "@substrate/light-client-extension-helpers/background"
|
||||
import { UserSignedExtensions } from "../../types/UserSignedExtension"
|
||||
import { Account, SignRequest } from "../types"
|
||||
|
||||
export type SignResponse = {
|
||||
userSignedExtensions: Partial<UserSignedExtensions>
|
||||
}
|
||||
|
||||
export type InternalSignRequest = {
|
||||
resolve: (props: SignResponse) => void
|
||||
reject: (reason?: any) => void
|
||||
} & SignRequest
|
||||
|
||||
export type Context = {
|
||||
lightClientPageHelper: LightClientPageHelper
|
||||
signRequests: Record<string, InternalSignRequest>
|
||||
port: chrome.runtime.Port
|
||||
notifyOnAccountsChanged: (accounts: Account[]) => void
|
||||
}
|
29
src/background/storage.ts
Normal file
29
src/background/storage.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { KeystoreV4WithMeta } from "./keystore"
|
||||
|
||||
const STORAGE_PREFIX = "ghost-extension/"
|
||||
|
||||
type StorageConfig = {
|
||||
keystore: KeystoreV4WithMeta
|
||||
}
|
||||
|
||||
type StorageKey = keyof StorageConfig
|
||||
|
||||
const getKey = (key: StorageKey) => `${STORAGE_PREFIX}${key}`
|
||||
|
||||
export const remove = (keyOrKeys: StorageKey | StorageKey[]): Promise<void> =>
|
||||
chrome.storage.local.remove(
|
||||
Array.isArray(keyOrKeys) ? keyOrKeys.map(getKey) : getKey(keyOrKeys),
|
||||
)
|
||||
|
||||
export const get = async <K extends StorageKey>(
|
||||
key: K,
|
||||
): Promise<StorageConfig[K] | undefined> => {
|
||||
const key_ = getKey(key)
|
||||
const { [key_]: value } = await chrome.storage.local.get([key_])
|
||||
return value
|
||||
}
|
||||
|
||||
export const set = <K extends StorageKey>(
|
||||
key: K,
|
||||
value: StorageConfig[K],
|
||||
): Promise<void> => chrome.storage.local.set({ [getKey(key)]: value })
|
122
src/background/types.ts
Normal file
122
src/background/types.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import type { Injected } from "@polkadot/extension-inject/types"
|
||||
import {
|
||||
UserSignedExtensionName,
|
||||
UserSignedExtensions,
|
||||
} from "../types/UserSignedExtension"
|
||||
|
||||
/**
|
||||
* 1:1 representation of chain spec JSON format with addition fields
|
||||
*/
|
||||
export type ChainSpec = {
|
||||
name: string
|
||||
id: string
|
||||
genesisHash: string
|
||||
relay_chain?: string
|
||||
isWellKnown: boolean
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type Account = {
|
||||
address: string
|
||||
}
|
||||
|
||||
export type KeystoreAccount =
|
||||
| ({ type: "Keyset" } & DerivationPath)
|
||||
| { type: "Keypair"; publicKey: string }
|
||||
|
||||
export type DerivationPath = {
|
||||
chainId: string
|
||||
path: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export type CryptoKey = {
|
||||
name: string
|
||||
scheme: "Sr25519" | "Ed25519" | "Ecdsa"
|
||||
accounts: KeystoreAccount[]
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type SignRequest = {
|
||||
url: string
|
||||
chainId: string
|
||||
address: string
|
||||
callData: string
|
||||
userSignedExtensions:
|
||||
| { type: "names"; names: UserSignedExtensionName[] }
|
||||
| { type: "values"; values: Partial<UserSignedExtensions> }
|
||||
}
|
||||
|
||||
export type InsertCryptoKeyArgs = {
|
||||
name: string
|
||||
scheme: "Sr25519" | "Ed25519" | "Ecdsa"
|
||||
createdAt: number
|
||||
} & (
|
||||
| {
|
||||
type: "Keyset"
|
||||
miniSecret: string
|
||||
derivationPaths: DerivationPath[]
|
||||
}
|
||||
| {
|
||||
type: "Keypair"
|
||||
privatekey: string
|
||||
}
|
||||
)
|
||||
|
||||
type KeyringState = {
|
||||
isLocked: boolean
|
||||
hasPassword: boolean
|
||||
}
|
||||
|
||||
export type UpdateCryptoKeyArgs = {
|
||||
name: string,
|
||||
deviation: number,
|
||||
}
|
||||
|
||||
export type RemoveCryptoKeyArgs = {
|
||||
name: string,
|
||||
}
|
||||
|
||||
export type RevealCryptoKeyArgs = {
|
||||
meta: string,
|
||||
index: number,
|
||||
}
|
||||
|
||||
export namespace Pjs {
|
||||
export type SignerPayloadJSON = Parameters<
|
||||
Exclude<Injected["signer"]["signPayload"], undefined>
|
||||
>[0]
|
||||
export type SignerPayloadRaw = Parameters<
|
||||
Exclude<Injected["signer"]["signRaw"], undefined>
|
||||
>[0]
|
||||
}
|
||||
|
||||
export type BackgroundRpcSpec = {
|
||||
getAccounts(chainId: string): Promise<Account[]>
|
||||
createTx(chainId: string, from: string, callData: string): Promise<string>
|
||||
pjsSignPayload(payload: Pjs.SignerPayloadJSON): Promise<string>
|
||||
// private methods
|
||||
getSignRequests(): Promise<Record<string, SignRequest>>
|
||||
approveSignRequest(
|
||||
id: string,
|
||||
userSignedExtensions: Partial<UserSignedExtensions>,
|
||||
): Promise<void>
|
||||
cancelSignRequest(id: string): Promise<void>
|
||||
lockKeyring(): Promise<void>
|
||||
resetKeyring(password: string): Promise<void>
|
||||
unlockKeyring(password: string): Promise<void>
|
||||
changePassword(currentPassword: string, newPassword: string): Promise<void>
|
||||
createPassword(password: string): Promise<void>
|
||||
getCryptoKeys(): Promise<CryptoKey[]>
|
||||
insertCryptoKey(args: InsertCryptoKeyArgs): Promise<void>
|
||||
updateCryptoKey(args: UpdateCryptoKeyArgs): Promise<void>
|
||||
getCryptoKey(name: string): Promise<CryptoKey | undefined>
|
||||
revealCryptoKey(name: string, index: number): Promise<string>
|
||||
clearCryptoKey(name: string, index: number): Promise<void>
|
||||
clearCryptoKeys(args: RemoveCryptoKeyArgs): Promise<void>
|
||||
getKeyringState(): Promise<KeyringState>
|
||||
|
||||
getChainSpecs(): Promise<ChainSpec[]>
|
||||
addChainSpec(chainSpec: string): Promise<void>
|
||||
removeChainSpec(genesisHash: string): Promise<void>
|
||||
}
|
3
src/background/utils.ts
Normal file
3
src/background/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function assert(condition: unknown, msg?: string): asserts condition {
|
||||
if (!condition) throw new Error(msg)
|
||||
}
|
38
src/components/Bootnodes.css
Normal file
38
src/components/Bootnodes.css
Normal file
@ -0,0 +1,38 @@
|
||||
.networkSelect {
|
||||
display: grid;
|
||||
grid-template-areas: "select";
|
||||
align-items: center;
|
||||
position: relative;
|
||||
|
||||
min-width: 15ch;
|
||||
max-width: 30ch;
|
||||
|
||||
border: 1px solid #999;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.networkSelect select {
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.networkSelect::after {
|
||||
grid-area: select;
|
||||
}
|
||||
|
||||
.networkSelect:focus + .focus {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
border: 2px solid var(--select-focus);
|
||||
border-radius: inherit;
|
||||
}
|
269
src/components/Bootnodes.tsx
Normal file
269
src/components/Bootnodes.tsx
Normal file
@ -0,0 +1,269 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { MdDeleteOutline } from "react-icons/md"
|
||||
import * as environment from "../environment"
|
||||
|
||||
import "./Bootnodes.css"
|
||||
import { Title, Switch } from "."
|
||||
import { helper } from "@substrate/light-client-extension-helpers/extension-page"
|
||||
import { wellKnownGenesisHashByChainId } from "../constants"
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface BootnodesType {
|
||||
checked: boolean
|
||||
bootnode: string
|
||||
}
|
||||
|
||||
const getBootNodes = async (chainId: string) =>
|
||||
(await helper.getChains()).find(
|
||||
({ genesisHash }) => genesisHash === wellKnownGenesisHashByChainId[chainId],
|
||||
)?.bootNodes ?? []
|
||||
|
||||
const setBootNodes = async (chainId: string, bootNodes: string[]) =>
|
||||
helper.setBootNodes(wellKnownGenesisHashByChainId[chainId], bootNodes)
|
||||
|
||||
// Add to localstorage the given bootnode for the given chain
|
||||
const saveToLocalStorage = async (
|
||||
chainName: string,
|
||||
bootnode: string,
|
||||
add: boolean,
|
||||
def: string[],
|
||||
) => {
|
||||
if (def.length === 0) throw new Error("Default Bootnodes should exist.")
|
||||
let res: string[]
|
||||
const chainBootnodes = await getBootNodes(chainName)
|
||||
res = chainBootnodes && Object.keys(chainBootnodes).length > 0
|
||||
? [...chainBootnodes]
|
||||
: [...def]
|
||||
add ? res.push(bootnode) : res.splice(res.indexOf(bootnode), 1)
|
||||
await setBootNodes(chainName, res)
|
||||
}
|
||||
|
||||
export const Bootnodes = () => {
|
||||
const [selectedChain, setSelectedChain] = useState<string>("casper_staging_testnet")
|
||||
const [defaultBn, setDefaultBn] = useState<BootnodesType[]>([])
|
||||
const [customBn, setCustomBn] = useState<BootnodesType[]>([])
|
||||
const [customBnInput, setCustomBnInput] = useState<string>("")
|
||||
const [selectedChainDefaultBn, setSelectedChainDefaultBn] = useState<string[]>([])
|
||||
|
||||
const [addMessage, setAddMessage] = useState<any>(undefined)
|
||||
const [bootnodeMsgClass, setBootnodeMsgClass] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (addMessage && !addMessage?.error) {
|
||||
setBootnodeMsgClass("pb-2 text-accent")
|
||||
setCustomBnInput("")
|
||||
} else {
|
||||
setBootnodeMsgClass("pb-2 text-destructive")
|
||||
}
|
||||
}, [addMessage])
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getBootNodes(selectedChain),
|
||||
environment.getDefaultBootnodes(selectedChain),
|
||||
]).then(([bootnodes, defaultBootnodes]) => {
|
||||
console.assert(defaultBootnodes, `Invalid chain name: ${selectedChain}`)
|
||||
defaultBootnodes ??= []
|
||||
setSelectedChainDefaultBn(defaultBootnodes)
|
||||
const tmpDef: BootnodesType[] = []
|
||||
const tmpCust: BootnodesType[] = []
|
||||
// When bootnodes do not exist assign and save the local ones
|
||||
if (!bootnodes?.length) {
|
||||
setBootNodes(selectedChain, defaultBootnodes)
|
||||
defaultBootnodes.forEach((b) => {
|
||||
tmpDef.push({ bootnode: b, checked: true })
|
||||
})
|
||||
} else {
|
||||
bootnodes?.forEach((b) => {
|
||||
defaultBootnodes?.length && defaultBootnodes?.includes(b)
|
||||
? tmpDef.push({ bootnode: b, checked: true })
|
||||
: tmpCust.push({ bootnode: b, checked: true })
|
||||
})
|
||||
}
|
||||
setDefaultBn(tmpDef)
|
||||
setCustomBn(tmpCust)
|
||||
})
|
||||
}, [selectedChain])
|
||||
|
||||
const checkMultiAddr = (addr: string) => {
|
||||
const ws = /\/(ip4|ip6|dns4|dns6|dns)\/([a-zA-Z0-9.-]+)\/tcp\/[0-9]{1,5}(\/(ws|wss|tls\/ws))?\/p2p\/[a-zA-Z1-9^Il0O]+/i
|
||||
const webrtc = /\/(ip4|ip6)\/(.*?)\/udp\/(.*?)\/webrtc\/certhash\/(.*?)\/p2p\/[a-zA-Z1-9^Il0O]+/i
|
||||
|
||||
if (!ws.test(addr) && !webrtc.test(addr))
|
||||
throw new Error("Provided multiaddress is not correct.")
|
||||
}
|
||||
|
||||
const alterBootnodes = async (
|
||||
bootnode: string,
|
||||
add: boolean,
|
||||
defaultBootnode: boolean,
|
||||
) => {
|
||||
// if bootnode belongs to the list (default) then it does not need to be validated as it
|
||||
// comes from the chainspecs. It can be saved to the local storage at once.
|
||||
try {
|
||||
if (!defaultBootnode) {
|
||||
// verify bootnode validity
|
||||
checkMultiAddr(customBnInput)
|
||||
}
|
||||
// Check if bootnode already exists in the default and custom lists
|
||||
if (
|
||||
selectedChainDefaultBn?.includes(customBnInput) ||
|
||||
customBn.map((c) => c.bootnode).includes(customBnInput)
|
||||
) {
|
||||
setAddMessage({ error: true, message: "Bootnode already exists in the list." })
|
||||
} else {
|
||||
await saveToLocalStorage(
|
||||
selectedChain,
|
||||
bootnode,
|
||||
add,
|
||||
selectedChainDefaultBn,
|
||||
)
|
||||
}
|
||||
const tmp = defaultBootnode ? [...defaultBn] : [...customBn]
|
||||
const i = tmp.findIndex((b) => b.bootnode === bootnode)
|
||||
if (i !== -1) {
|
||||
tmp[i].checked = add
|
||||
} else {
|
||||
tmp.push({ bootnode, checked: true })
|
||||
}
|
||||
defaultBootnode ? setDefaultBn(tmp) : setCustomBn(tmp)
|
||||
setCustomBnInput("")
|
||||
} catch (err) {
|
||||
setAddMessage({ error: true, message: (err as Error).message.replace(/^\w/, (c) => c.toUpperCase()) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<h2 className="pb-2 space-y-2 text-3xl font-semibold md:pb-4">
|
||||
Networks
|
||||
</h2>
|
||||
<div className="p-4 bg-muted rounded">
|
||||
<Select
|
||||
onValueChange={(networkName) => {
|
||||
setSelectedChain(networkName)
|
||||
setCustomBnInput("")
|
||||
setAddMessage(undefined)
|
||||
}}
|
||||
name={selectedChain}
|
||||
value={selectedChain}
|
||||
>
|
||||
<SelectTrigger className="sm:w-[250px] w-[100%]" data-testid="scheme-select">
|
||||
<SelectValue placeholder="Select Network" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="sm:w-[250px] w-[100%]">
|
||||
<SelectGroup>
|
||||
<SelectItem data-testid="scheme-casper_staging_testnet" value="casper_staging_testnet">
|
||||
Casper
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Title className="mt-8">Bootnodes</Title>
|
||||
<Title titleType="small">Default</Title>
|
||||
<div className="mb-8">
|
||||
{selectedChainDefaultBn?.map((bn) => (
|
||||
<div className="flex items-center mb-2 leading-4 wrap">
|
||||
<div className="sm:w-11/12 w-1/2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{bn}
|
||||
</div>
|
||||
<Switch
|
||||
className="w-1/2 sm:w-1/12"
|
||||
bootnode={bn}
|
||||
alterBootnodes={alterBootnodes}
|
||||
defaultBootnode={true}
|
||||
isChecked={defaultBn.map((d) => d.bootnode).includes(bn)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Title titleType="small" className="text-accent">Custom</Title>
|
||||
<div className="mb-8">
|
||||
{customBn.map((c) => (
|
||||
<div className="flex items-center mb-2 leading-4">
|
||||
<div className="sm:w-11/12 w-1/2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{c.bootnode}
|
||||
</div>
|
||||
<div className="flex w-1/2 sm:w-1/12 ml-8">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex items-center text-white h-6 bg-red-600 rounded-full hover:bg-red-700"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await saveToLocalStorage(
|
||||
selectedChain,
|
||||
c.bootnode,
|
||||
false,
|
||||
selectedChainDefaultBn,
|
||||
)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
setCustomBn(customBn.filter((f) => f.bootnode !== c.bootnode))
|
||||
}}
|
||||
>
|
||||
<MdDeleteOutline className="text-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Title>Add custom Bootnode</Title>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex sm:flex-row flex-col gap-6 justify-between mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter bootnode address"
|
||||
className="sm:w-5/6 w-full"
|
||||
value={customBnInput}
|
||||
onChange={(v) => {
|
||||
addMessage && setAddMessage(undefined)
|
||||
setCustomBnInput(v.target.value)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="full"
|
||||
className="sm:w-1/6 w-full"
|
||||
disabled={!customBnInput}
|
||||
onClick={() => {
|
||||
if (
|
||||
selectedChainDefaultBn?.includes(customBnInput) ||
|
||||
customBn.map((c) => c.bootnode).includes(customBnInput)
|
||||
) {
|
||||
setAddMessage({
|
||||
error: true,
|
||||
message: "Bootnode already exists in the list.",
|
||||
})
|
||||
} else {
|
||||
alterBootnodes(
|
||||
customBnInput,
|
||||
true,
|
||||
selectedChainDefaultBn?.includes(customBnInput),
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className={bootnodeMsgClass}>
|
||||
{addMessage && Object.keys(addMessage) ? addMessage.message : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
106
src/components/BraveModal.tsx
Normal file
106
src/components/BraveModal.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import * as environment from "../environment"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Props {
|
||||
isOptions?: boolean
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const openInNewTab = (url: string): void => {
|
||||
const newWindow = window.open(url, "_blank", "noopener,noreferrer")
|
||||
if (newWindow) newWindow.opener = null
|
||||
}
|
||||
|
||||
export const BraveModal = ({ show, isOptions }: Props) => {
|
||||
const [showModal, setShowModal] = useState<boolean>(show)
|
||||
|
||||
useEffect(() => {
|
||||
setShowModal(show)
|
||||
}, [show])
|
||||
|
||||
return (
|
||||
<div
|
||||
id="defaultModal"
|
||||
className={`${isOptions ? "absolute " : ""} ${
|
||||
showModal ? "" : "hidden "
|
||||
}overflow-y-auto overflow-x-hidden fixed bottom-0 right-0 left-0 z-50 w-full h-modal`}
|
||||
>
|
||||
<div className="relative w-full h-full md:h-auto">
|
||||
<div className={"border-2 border-solid relative bg-muted shadow rounded-t-lg"}>
|
||||
<div className="flex justify-between items-center py-2 px-4 rounded-t">
|
||||
<h5 className="text-base font-semibold text-accent">
|
||||
Attention Brave Users
|
||||
</h5>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="sr-only">Close modal</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="py-2 px-4 space-y-2">
|
||||
<p className="text-xs leading-relaxed text-primary">
|
||||
Due to a{" "}
|
||||
<a
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
className="font-bold underline hover:text-accent"
|
||||
href="https://github.com/brave/brave-browser/issues/19990"
|
||||
>
|
||||
recent Brave update (1.36.109)
|
||||
</a>
|
||||
, some results may not display correctly. Disabling, in Brave
|
||||
settings, the{" "}
|
||||
<span className="font-bold"> Restrict Websocket Pool </span>flag
|
||||
and restart browser will help.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${
|
||||
isOptions ? "justify-start" : "justify-between"
|
||||
}" flex pt-2 pb-4 px-4 space-x-2 border-gray-200 dark:border-gray-600"`}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// TODO: this should produce a react-style event instead of setting the value directly
|
||||
setShowModal(false)
|
||||
environment.set({ type: "braveSetting" }, true)
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => openInNewTab("https://github.com/brave/brave-browser/issues/19990")}
|
||||
type="button"
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
26
src/components/IconWeb3.tsx
Normal file
26
src/components/IconWeb3.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { FunctionComponent } from "react"
|
||||
import { MdOutlineGridView } from "react-icons/md"
|
||||
import "../main.css"
|
||||
|
||||
interface Props {
|
||||
isWellKnown?: boolean
|
||||
children?: string
|
||||
}
|
||||
|
||||
export const IconWeb3: FunctionComponent<Props> = ({
|
||||
children,
|
||||
isWellKnown,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{isWellKnown && children ? (
|
||||
<>
|
||||
<MdOutlineGridView />
|
||||
<span className="flex items-center text-xl icon">{children}</span>
|
||||
</>
|
||||
) : (
|
||||
<MdOutlineGridView />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
9
src/components/Layout.tsx
Normal file
9
src/components/Layout.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react"
|
||||
|
||||
export type Props = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Layout: React.FC<Props> = ({ children }) => {
|
||||
return <div className="bg-muted mx-auto px-6 py-8">{children}</div>
|
||||
}
|
39
src/components/Layout2.tsx
Normal file
39
src/components/Layout2.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type Props = {
|
||||
innerClassName?: string
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* This Layout is the new layout but is called Layout2 until all pages
|
||||
* are migrated to the new layout.
|
||||
*/
|
||||
export const Layout2: React.FC<Props> = ({ children, className, innerClassName }) => {
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
"min-h-screen",
|
||||
"font-sans",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background",
|
||||
"w-[400px] h-[600px]",
|
||||
"flex flex-col",
|
||||
"shadow-sm",
|
||||
"rounded-none lg:rounded",
|
||||
innerClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
68
src/components/Logo.tsx
Normal file
68
src/components/Logo.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
const HeaderIcon: React.FC<IconProps> = (props) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 722.49 211.56">
|
||||
<defs>
|
||||
<style>{`
|
||||
.uuid-78f6a6af-10fe-4831-9ced-7187d1432e48 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.uuid-268138db-7fdf-4ed3-88f2-b8d32bba180e {
|
||||
mix-blend-mode: color-burn;
|
||||
}`}
|
||||
</style>
|
||||
</defs>
|
||||
<g class="uuid-78f6a6af-10fe-4831-9ced-7187d1432e48">
|
||||
<g id="uuid-c93f97e5-2f4a-47f0-9a31-d2f07858ae05" data-name="Layer 2">
|
||||
<g id="uuid-441c66b0-dfc2-459a-90e6-28d9bd55620b" data-name="ghostWallet">
|
||||
<g id="uuid-e61fd7bf-260d-4b17-b1fb-bb8f42140ffa" data-name="Wallet">
|
||||
<g>
|
||||
<path d="m499.19,76.35c1.07,3.6,2.2,7.35,3.4,11.25,1.2,3.9,2.45,7.8,3.75,11.7,1.3,3.9,2.6,7.73,3.9,11.5,1.3,3.77,2.55,7.32,3.75,10.65,1-3.6,1.97-7.52,2.9-11.75.93-4.23,1.87-8.63,2.8-13.2.93-4.57,1.85-9.22,2.75-13.95.9-4.73,1.75-9.4,2.55-14h13c-2.33,12.53-4.93,24.58-7.79,36.15-2.86,11.57-6.06,22.62-9.59,33.15h-12.12c-5.17-13.47-10.17-28.17-15-44.1-2.47,8.07-4.97,15.73-7.51,23-2.54,7.27-5.07,14.3-7.61,21.1h-12.02c-3.59-10.53-6.8-21.58-9.63-33.15-2.83-11.57-5.4-23.62-7.73-36.15h13.5c.8,4.53,1.65,9.17,2.55,13.9.9,4.73,1.83,9.38,2.8,13.95.97,4.57,1.95,8.98,2.95,13.25,1,4.27,2,8.2,3,11.8,1.27-3.4,2.55-6.98,3.85-10.75,1.3-3.77,2.58-7.6,3.85-11.5,1.27-3.9,2.48-7.78,3.65-11.65,1.17-3.87,2.25-7.6,3.25-11.2h10.8Z"/>
|
||||
<path d="m557.79,84.25c4,0,7.37.5,10.1,1.5,2.73,1,4.92,2.4,6.55,4.2,1.63,1.8,2.8,3.98,3.5,6.55.7,2.57,1.05,5.38,1.05,8.45v31.7c-1.87.4-4.68.88-8.45,1.45-3.77.57-8.02.85-12.75.85-3.13,0-6-.3-8.6-.9-2.6-.6-4.82-1.57-6.65-2.9-1.83-1.33-3.27-3.07-4.3-5.2-1.03-2.13-1.55-4.77-1.55-7.9s.58-5.53,1.75-7.6c1.17-2.07,2.75-3.75,4.75-5.05,2-1.3,4.32-2.23,6.95-2.8,2.63-.57,5.38-.85,8.25-.85,1.33,0,2.73.08,4.2.25,1.46.17,3.03.45,4.7.85v-2c0-1.4-.17-2.73-.5-4-.33-1.27-.92-2.38-1.75-3.35-.83-.97-1.93-1.72-3.3-2.25-1.37-.53-3.08-.8-5.15-.8-2.8,0-5.37.2-7.7.6-2.33.4-4.23.87-5.7,1.4l-1.5-9.8c1.53-.53,3.77-1.07,6.7-1.6,2.93-.53,6.07-.8,9.4-.8Zm1,45.1c3.73,0,6.57-.2,8.5-.6v-13.4c-.67-.2-1.63-.4-2.9-.6-1.27-.2-2.67-.3-4.2-.3-1.33,0-2.68.1-4.05.3-1.37.2-2.6.57-3.7,1.1-1.1.53-1.98,1.28-2.65,2.25-.67.97-1,2.18-1,3.65,0,2.87.9,4.85,2.7,5.95s4.23,1.65,7.3,1.65Z"/>
|
||||
<path d="m606.99,138.85c-3.6-.07-6.58-.47-8.95-1.2-2.37-.73-4.25-1.78-5.65-3.15-1.4-1.37-2.38-3.07-2.95-5.1-.57-2.03-.85-4.35-.85-6.95v-60.2l12.1-2v59.9c0,1.47.12,2.7.35,3.7.23,1,.65,1.85,1.25,2.55.6.7,1.42,1.23,2.45,1.6,1.03.37,2.35.65,3.95.85l-1.7,10Z"/>
|
||||
<path d="m631.69,138.85c-3.6-.07-6.58-.47-8.95-1.2-2.37-.73-4.25-1.78-5.65-3.15-1.4-1.37-2.38-3.07-2.95-5.1-.57-2.03-.85-4.35-.85-6.95v-60.2l12.1-2v59.9c0,1.47.12,2.7.35,3.7.23,1,.65,1.85,1.25,2.55.6.7,1.42,1.23,2.45,1.6,1.03.37,2.35.65,3.95.85l-1.7,10Z"/>
|
||||
<path d="m635.29,111.95c0-4.6.68-8.63,2.05-12.1,1.37-3.47,3.18-6.35,5.45-8.65,2.27-2.3,4.87-4.03,7.8-5.2,2.93-1.17,5.93-1.75,9-1.75,7.2,0,12.82,2.23,16.85,6.7,4.03,4.47,6.05,11.13,6.05,20,0,.67-.02,1.42-.05,2.25-.03.83-.08,1.58-.15,2.25h-34.5c.33,4.2,1.82,7.45,4.45,9.75,2.63,2.3,6.45,3.45,11.45,3.45,2.93,0,5.62-.27,8.05-.8,2.43-.53,4.35-1.1,5.75-1.7l1.6,9.9c-.67.33-1.58.68-2.75,1.05-1.17.37-2.5.7-4,1s-3.12.55-4.85.75c-1.73.2-3.5.3-5.3.3-4.6,0-8.6-.68-12-2.05-3.4-1.37-6.2-3.27-8.4-5.7-2.2-2.43-3.83-5.3-4.9-8.6-1.07-3.3-1.6-6.92-1.6-10.85Zm35.1-5.4c0-1.67-.23-3.25-.7-4.75-.47-1.5-1.15-2.8-2.05-3.9-.9-1.1-2-1.97-3.3-2.6-1.3-.63-2.85-.95-4.65-.95s-3.5.35-4.9,1.05c-1.4.7-2.58,1.62-3.55,2.75-.97,1.13-1.72,2.43-2.25,3.9-.53,1.47-.9,2.97-1.1,4.5h22.5Z"/>
|
||||
<path d="m689.69,71.75l12.1-2v15.8h18.6v10.1h-18.6v21.3c0,4.2.67,7.2,2,9,1.33,1.8,3.6,2.7,6.8,2.7,2.2,0,4.15-.23,5.85-.7,1.7-.47,3.05-.9,4.05-1.3l2,9.6c-1.4.6-3.23,1.22-5.5,1.85-2.27.63-4.93.95-8,.95-3.73,0-6.85-.5-9.35-1.5s-4.48-2.45-5.95-4.35c-1.47-1.9-2.5-4.2-3.1-6.9-.6-2.7-.9-5.78-.9-9.25v-45.3Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path d="m499.19,76.35c1.07,3.6,2.2,7.35,3.4,11.25,1.2,3.9,2.45,7.8,3.75,11.7,1.3,3.9,2.6,7.73,3.9,11.5,1.3,3.77,2.55,7.32,3.75,10.65,1-3.6,1.97-7.52,2.9-11.75.93-4.23,1.87-8.63,2.8-13.2.93-4.57,1.85-9.22,2.75-13.95.9-4.73,1.75-9.4,2.55-14h13c-2.33,12.53-4.93,24.58-7.79,36.15-2.86,11.57-6.06,22.62-9.59,33.15h-12.12c-5.17-13.47-10.17-28.17-15-44.1-2.47,8.07-4.97,15.73-7.51,23-2.54,7.27-5.07,14.3-7.61,21.1h-12.02c-3.59-10.53-6.8-21.58-9.63-33.15-2.83-11.57-5.4-23.62-7.73-36.15h13.5c.8,4.53,1.65,9.17,2.55,13.9.9,4.73,1.83,9.38,2.8,13.95.97,4.57,1.95,8.98,2.95,13.25,1,4.27,2,8.2,3,11.8,1.27-3.4,2.55-6.98,3.85-10.75,1.3-3.77,2.58-7.6,3.85-11.5,1.27-3.9,2.48-7.78,3.65-11.65,1.17-3.87,2.25-7.6,3.25-11.2h10.8Z"/>
|
||||
<path d="m557.79,84.25c4,0,7.37.5,10.1,1.5,2.73,1,4.92,2.4,6.55,4.2,1.63,1.8,2.8,3.98,3.5,6.55.7,2.57,1.05,5.38,1.05,8.45v31.7c-1.87.4-4.68.88-8.45,1.45-3.77.57-8.02.85-12.75.85-3.13,0-6-.3-8.6-.9-2.6-.6-4.82-1.57-6.65-2.9-1.83-1.33-3.27-3.07-4.3-5.2-1.03-2.13-1.55-4.77-1.55-7.9s.58-5.53,1.75-7.6c1.17-2.07,2.75-3.75,4.75-5.05,2-1.3,4.32-2.23,6.95-2.8,2.63-.57,5.38-.85,8.25-.85,1.33,0,2.73.08,4.2.25,1.46.17,3.03.45,4.7.85v-2c0-1.4-.17-2.73-.5-4-.33-1.27-.92-2.38-1.75-3.35-.83-.97-1.93-1.72-3.3-2.25-1.37-.53-3.08-.8-5.15-.8-2.8,0-5.37.2-7.7.6-2.33.4-4.23.87-5.7,1.4l-1.5-9.8c1.53-.53,3.77-1.07,6.7-1.6,2.93-.53,6.07-.8,9.4-.8Zm1,45.1c3.73,0,6.57-.2,8.5-.6v-13.4c-.67-.2-1.63-.4-2.9-.6-1.27-.2-2.67-.3-4.2-.3-1.33,0-2.68.1-4.05.3-1.37.2-2.6.57-3.7,1.1-1.1.53-1.98,1.28-2.65,2.25-.67.97-1,2.18-1,3.65,0,2.87.9,4.85,2.7,5.95s4.23,1.65,7.3,1.65Z"/>
|
||||
<path d="m606.99,138.85c-3.6-.07-6.58-.47-8.95-1.2-2.37-.73-4.25-1.78-5.65-3.15-1.4-1.37-2.38-3.07-2.95-5.1-.57-2.03-.85-4.35-.85-6.95v-60.2l12.1-2v59.9c0,1.47.12,2.7.35,3.7.23,1,.65,1.85,1.25,2.55.6.7,1.42,1.23,2.45,1.6,1.03.37,2.35.65,3.95.85l-1.7,10Z"/>
|
||||
<path d="m631.69,138.85c-3.6-.07-6.58-.47-8.95-1.2-2.37-.73-4.25-1.78-5.65-3.15-1.4-1.37-2.38-3.07-2.95-5.1-.57-2.03-.85-4.35-.85-6.95v-60.2l12.1-2v59.9c0,1.47.12,2.7.35,3.7.23,1,.65,1.85,1.25,2.55.6.7,1.42,1.23,2.45,1.6,1.03.37,2.35.65,3.95.85l-1.7,10Z"/>
|
||||
<path d="m635.29,111.95c0-4.6.68-8.63,2.05-12.1,1.37-3.47,3.18-6.35,5.45-8.65,2.27-2.3,4.87-4.03,7.8-5.2,2.93-1.17,5.93-1.75,9-1.75,7.2,0,12.82,2.23,16.85,6.7,4.03,4.47,6.05,11.13,6.05,20,0,.67-.02,1.42-.05,2.25-.03.83-.08,1.58-.15,2.25h-34.5c.33,4.2,1.82,7.45,4.45,9.75,2.63,2.3,6.45,3.45,11.45,3.45,2.93,0,5.62-.27,8.05-.8,2.43-.53,4.35-1.1,5.75-1.7l1.6,9.9c-.67.33-1.58.68-2.75,1.05-1.17.37-2.5.7-4,1s-3.12.55-4.85.75c-1.73.2-3.5.3-5.3.3-4.6,0-8.6-.68-12-2.05-3.4-1.37-6.2-3.27-8.4-5.7-2.2-2.43-3.83-5.3-4.9-8.6-1.07-3.3-1.6-6.92-1.6-10.85Zm35.1-5.4c0-1.67-.23-3.25-.7-4.75-.47-1.5-1.15-2.8-2.05-3.9-.9-1.1-2-1.97-3.3-2.6-1.3-.63-2.85-.95-4.65-.95s-3.5.35-4.9,1.05c-1.4.7-2.58,1.62-3.55,2.75-.97,1.13-1.72,2.43-2.25,3.9-.53,1.47-.9,2.97-1.1,4.5h22.5Z"/>
|
||||
<path d="m689.69,71.75l12.1-2v15.8h18.6v10.1h-18.6v21.3c0,4.2.67,7.2,2,9,1.33,1.8,3.6,2.7,6.8,2.7,2.2,0,4.15-.23,5.85-.7,1.7-.47,3.05-.9,4.05-1.3l2,9.6c-1.4.6-3.23,1.22-5.5,1.85-2.27.63-4.93.95-8,.95-3.73,0-6.85-.5-9.35-1.5s-4.48-2.45-5.95-4.35c-1.47-1.9-2.5-4.2-3.1-6.9-.6-2.7-.9-5.78-.9-9.25v-45.3Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="uuid-13b91de4-3fc3-44cf-bfc6-f264768961b4" data-name="ghost">
|
||||
<path d="m258.52,132.56c0,8.33-2.12,14.43-6.35,18.3-4.23,3.87-10.72,5.8-19.45,5.8-3.2,0-6.32-.27-9.35-.8-3.03-.53-5.78-1.23-8.25-2.1l2.2-10.3c2.07.87,4.42,1.57,7.05,2.1,2.63.53,5.48.8,8.55.8,4.87,0,8.33-1,10.4-3,2.07-2,3.1-4.97,3.1-8.9v-2c-1.2.6-2.78,1.2-4.75,1.8-1.97.6-4.22.9-6.75.9-3.33,0-6.38-.53-9.15-1.6-2.77-1.07-5.13-2.63-7.1-4.7-1.97-2.07-3.5-4.65-4.6-7.75-1.1-3.1-1.65-6.68-1.65-10.75,0-3.8.58-7.3,1.75-10.5,1.17-3.2,2.87-5.93,5.1-8.2,2.23-2.27,4.95-4.03,8.15-5.3,3.2-1.27,6.83-1.9,10.9-1.9s7.67.3,11.2.9c3.53.6,6.53,1.23,9,1.9v45.3Zm-33.7-22.2c0,5.13,1.12,8.88,3.35,11.25,2.23,2.37,5.12,3.55,8.65,3.55,1.93,0,3.75-.27,5.45-.8,1.7-.53,3.08-1.17,4.15-1.9v-27.1c-.87-.2-1.93-.38-3.2-.55-1.27-.17-2.87-.25-4.8-.25-4.4,0-7.77,1.45-10.1,4.35-2.33,2.9-3.5,6.72-3.5,11.45Z"/>
|
||||
<path d="m266.42,137.85V62.25l12.1-2v25.9c1.33-.47,2.88-.87,4.65-1.2,1.77-.33,3.52-.5,5.25-.5,4.2,0,7.68.58,10.45,1.75,2.77,1.17,4.98,2.8,6.65,4.9,1.67,2.1,2.85,4.62,3.55,7.55.7,2.93,1.05,6.2,1.05,9.8v29.4h-12.1v-27.5c0-2.8-.18-5.18-.55-7.15-.37-1.97-.97-3.57-1.8-4.8-.83-1.23-1.95-2.13-3.35-2.7-1.4-.57-3.13-.85-5.2-.85-1.6,0-3.23.17-4.9.5-1.67.33-2.9.63-3.7.9v41.6h-12.1Z"/>
|
||||
<path d="m366.92,111.65c0,4.13-.6,7.9-1.8,11.3-1.2,3.4-2.9,6.3-5.1,8.7-2.2,2.4-4.85,4.27-7.95,5.6-3.1,1.33-6.52,2-10.25,2s-7.13-.67-10.2-2c-3.07-1.33-5.7-3.2-7.9-5.6s-3.92-5.3-5.15-8.7c-1.23-3.4-1.85-7.17-1.85-11.3s.62-7.88,1.85-11.25c1.23-3.37,2.96-6.25,5.2-8.65,2.23-2.4,4.88-4.25,7.95-5.55,3.07-1.3,6.43-1.95,10.1-1.95s7.05.65,10.15,1.95c3.1,1.3,5.75,3.15,7.95,5.55,2.2,2.4,3.92,5.29,5.15,8.65,1.23,3.37,1.85,7.12,1.85,11.25Zm-12.4,0c0-5.2-1.12-9.32-3.35-12.35-2.23-3.03-5.35-4.55-9.35-4.55s-7.12,1.52-9.35,4.55c-2.23,3.03-3.35,7.15-3.35,12.35s1.12,9.43,3.35,12.5c2.23,3.07,5.35,4.6,9.35,4.6s7.12-1.53,9.35-4.6c2.23-3.07,3.35-7.23,3.35-12.5Z"/>
|
||||
<path d="m387.52,129.15c3.2,0,5.53-.38,7-1.15,1.47-.77,2.2-2.08,2.2-3.95,0-1.73-.78-3.17-2.35-4.3-1.57-1.13-4.15-2.37-7.75-3.7-2.2-.8-4.22-1.65-6.05-2.55-1.83-.9-3.42-1.95-4.75-3.15-1.33-1.2-2.38-2.65-3.15-4.35-.77-1.7-1.15-3.78-1.15-6.25,0-4.8,1.77-8.58,5.3-11.35,3.53-2.77,8.33-4.15,14.4-4.15,3.07,0,6,.29,8.8.85,2.8.57,4.9,1.12,6.3,1.65l-2.2,9.8c-1.33-.6-3.04-1.15-5.1-1.65-2.07-.5-4.47-.75-7.2-.75-2.47,0-4.47.42-6,1.25-1.53.83-2.3,2.12-2.3,3.85,0,.87.15,1.63.45,2.3.3.67.82,1.28,1.55,1.85.73.57,1.7,1.13,2.9,1.7,1.2.57,2.67,1.15,4.4,1.75,2.87,1.07,5.3,2.12,7.3,3.15,2,1.04,3.65,2.2,4.95,3.5s2.25,2.79,2.85,4.45c.6,1.67.9,3.67.9,6,0,5-1.85,8.78-5.55,11.35-3.7,2.57-8.98,3.85-15.85,3.85-4.6,0-8.3-.38-11.1-1.15-2.8-.77-4.77-1.38-5.9-1.85l2.1-10.1c1.8.73,3.95,1.43,6.45,2.1,2.5.67,5.35,1,8.55,1Z"/>
|
||||
<path d="m414.52,71.75l12.1-2v15.8h18.6v10.1h-18.6v21.3c0,4.2.67,7.2,2,9,1.33,1.8,3.6,2.7,6.8,2.7,2.2,0,4.15-.23,5.85-.7,1.7-.47,3.05-.9,4.05-1.3l2,9.6c-1.4.6-3.23,1.22-5.5,1.85-2.27.63-4.93.95-8,.95-3.73,0-6.85-.5-9.35-1.5s-4.48-2.45-5.95-4.35c-1.47-1.9-2.5-4.2-3.1-6.9-.6-2.7-.9-5.78-.9-9.25v-45.3Z"/>
|
||||
</g>
|
||||
<g id="uuid-31d568be-350a-4bf8-9ecd-aa0b82fa524a" data-name="Solid">
|
||||
<path id="uuid-74c4d7ce-4375-4dd5-a130-f5450bf30268" data-name="Inner" class="uuid-268138db-7fdf-4ed3-88f2-b8d32bba180e" d="m92.11,176.98c-.13,0-.27,0-.4-.02l-.36-.05-5.28-1.06-1.67-.44c-.41-.11-.79-.21-1.17-.31-.77-.2-1.53-.4-2.29-.62l-.09-.03-3.37-1.11c-4.93-1.77-9.24-3.78-13.19-6.15-8.55-5.19-15.79-12.3-20.95-20.58-5.08-8.09-8.49-17.66-9.84-27.64-.69-5.18-.85-10.17-.47-14.83.4-4.83,1.23-9.01,2.53-12.78.52-1.51,1.67-2.74,3.13-3.37,1.47-.63,3.15-.63,4.61.03,2.76,1.24,4.08,4.42,3.01,7.24-1.07,2.82-1.82,6.16-2.24,9.93-.43,3.88-.42,8.09.04,12.51.91,8.55,3.57,16.78,7.69,23.79,4.16,7.13,10.14,13.35,17.29,17.98,3.34,2.14,7.07,4.01,11.38,5.7l3.1,1.12c.63.21,1.27.39,1.9.58.4.12.81.23,1.21.36l1.5.44,5.03,1.17c1.99.56,3.21,2.49,2.9,4.6-.3,2.06-2.03,3.56-4.03,3.56Zm60.42-101.05c1.37-.81,2.34-2.17,2.67-3.73.33-1.56-.01-3.21-.94-4.51-2.62-3.69-5.84-7.07-9.55-10.03-3.98-3.2-8.44-6-13.27-8.31-9.41-4.51-19.81-6.93-30.08-6.99-5.27-.05-10.45.57-15.47,1.78-5,1.21-9.88,3.07-14.53,5.53-4.38,2.32-8.66,5.23-12.74,8.69-3.59,3.14-7.01,6.72-10.45,10.95l-.3.43c-1.02,1.7-.62,3.9.95,5.23,1.61,1.36,3.94,1.35,5.44-.03l.29-.3c3.24-3.76,6.41-6.91,9.66-9.6,3.63-2.91,7.43-5.35,11.3-7.26,4.03-1.99,8.23-3.46,12.49-4.37,4.29-.91,8.72-1.29,13.17-1.15,8.78.3,17.6,2.6,25.52,6.68,4.02,2.06,7.7,4.51,10.96,7.28,2.85,2.4,5.28,5.09,7.22,7.99,1.09,1.64,2.9,2.53,4.74,2.53,1,0,2-.26,2.92-.81Zm-33.71,101.23c8.14-2.46,15.81-6.42,22.8-11.75,7.53-5.76,13.88-12.95,18.38-20.77,4.67-8.09,7.51-17.26,8.22-26.49.72-8.52-.39-17.67-3.31-27.18l-.1-.31-.15-.29c-.93-1.81-3.03-2.66-4.99-2.02-1.98.65-3.19,2.65-2.82,4.65l.07.29c2.34,8.54,3.09,16.6,2.23,23.99-.85,7.85-3.5,15.56-7.67,22.29-4.03,6.55-9.65,12.51-16.24,17.24-5.99,4.29-12.77,7.53-19.61,9.37-2.96.79-4.78,3.87-4.05,6.86.37,1.52,1.36,2.83,2.72,3.61.87.5,1.86.75,2.85.75.56,0,1.12-.08,1.66-.25Z"/>
|
||||
<path id="uuid-3a66e28c-1db5-41c3-8097-56a1d6ecbc3c" data-name="Outer" class="uuid-268138db-7fdf-4ed3-88f2-b8d32bba180e" d="m200.31,158.83c-.09,9.87-8.19,17.83-18.06,17.74-.96,0-1.9-.09-2.81-.25-5.68,6.4-12.12,12.17-19.09,17.06-11.88,8.36-25.54,14.12-39.48,16.66-5.79,1.07-11.71,1.57-17.66,1.52-8.37-.08-16.78-1.26-24.91-3.52-12.48-3.38-25.13-9.66-37.58-18.66l-.11-.08c-1.95-1.51-2.34-4.3-.88-6.34,1.43-2.02,4.17-2.59,6.22-1.3l.1.07c11.78,7.87,23.53,13.2,34.93,15.85,12.43,2.98,25.38,3.13,37.47.44,12.06-2.66,23.75-8.1,33.82-15.74,5.47-4.14,10.51-8.91,14.97-14.13-1.75-2.79-2.75-6.09-2.71-9.62.09-9.87,8.19-17.83,18.07-17.74,9.87.09,17.83,8.19,17.74,18.07Zm-158.13-.57c-1.77-4.44-5.17-7.93-9.56-9.81-3.27-1.4-6.72-1.75-9.99-1.2-3.68-7.45-6.35-15.61-7.88-24.17-2.6-14.4-1.83-29.01,2.22-42.26,2.09-6.84,5.06-13.35,8.83-19.36,3.73-5.95,8.33-11.5,13.65-16.51,5.03-4.74,10.89-9.13,17.34-13.02,5.98-3.48,12.52-6.58,19.97-9.47l.59-.23.49-.39c1.58-1.26,2.18-3.4,1.49-5.32-.8-2.21-3.1-3.48-5.36-2.95l-.42.12c-8.04,2.82-15.13,5.92-21.75,9.49-7.27,4.07-13.88,8.71-19.62,13.78-6.19,5.45-11.59,11.58-16.05,18.2-4.54,6.74-8.19,14.1-10.84,21.87C.14,92.06-1.28,108.75,1.16,125.3c1.56,10.65,4.68,20.85,9.16,30.18-.46.74-.87,1.52-1.22,2.35-3.9,9.07.31,19.62,9.39,23.51,2.12.91,4.32,1.38,6.5,1.44,7.14.22,14.03-3.88,17.02-10.83,1.89-4.39,1.95-9.26.18-13.7Zm55.75-123.09c1.44.4,2.91.61,4.37.64,3.2.07,6.37-.73,9.23-2.35,2.92-1.67,5.25-4.07,6.82-6.95,7.24,1.49,14.46,3.9,21.38,7.19,12.16,5.82,23,14.16,31.34,24.13,8.54,10.16,14.66,22.39,17.7,35.41,2.93,12.08,3.22,25.66.87,40.35l-.05.52c-.07,2.31,1.61,4.33,3.91,4.7.22.04.44.05.66.06,1.83.04,3.52-.96,4.33-2.63l.24-.48.1-.53c3.11-15.89,3.27-30.81.5-44.31-2.92-14.81-9.39-28.93-18.71-40.82-9.1-11.67-21.11-21.6-34.75-28.72-8.45-4.37-17.37-7.59-26.38-9.58-.34-.94-.75-1.87-1.26-2.76-2.37-4.15-6.21-7.14-10.82-8.4-4.61-1.26-9.44-.66-13.59,1.71-4.15,2.37-7.14,6.21-8.4,10.82-1.26,4.61-.66,9.44,1.71,13.59,2.37,4.15,6.21,7.14,10.82,8.4Z"/>
|
||||
</g>
|
||||
<g id="uuid-591b2e65-6fc2-422c-8835-abb238a7ba2d" data-name="Wallet Icon">
|
||||
<path d="m103.3,132.26c-4.84,0-8.8-3.96-8.8-8.8v-22.41c0-4.84,3.96-8.8,8.8-8.8h30.04c-1.67-4.82-6.25-8.31-11.62-8.31h-45.37c-6.77,0-12.31,5.54-12.31,12.31v31.37c0,6.77,5.54,12.31,12.31,12.31h45.37c5.14,0,9.56-3.19,11.4-7.69h-29.82Z"/>
|
||||
<path d="m132.49,96.24h-25.96c-3.88,0-7.05,3.17-7.05,7.05v17.95c0,3.88,3.17,7.05,7.05,7.05h25.96c3.88,0,7.05-3.17,7.05-7.05v-17.95c0-3.88-3.17-7.05-7.05-7.05Zm-23.45,20.71c-2.49,0-4.5-2.01-4.5-4.5s2.01-4.5,4.5-4.5,4.5,2.01,4.5,4.5-2.01,4.5-4.5,4.5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface LogoProps {
|
||||
cName?: string
|
||||
}
|
||||
|
||||
const Logo = ({ cName }: LogoProps) => {
|
||||
return (
|
||||
<div className={cName}>
|
||||
<HeaderIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
15
src/components/MenuContent.tsx
Normal file
15
src/components/MenuContent.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from "react"
|
||||
|
||||
type MenuContentProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const MenuContent = ({ children }: MenuContentProps) => (
|
||||
<div className="flex flex-col w-full min-w-0 mb-6 break-words rounded">
|
||||
<div className="flex-auto px-4 py-5">
|
||||
<div className="tab-content tab-space">
|
||||
<div className="block">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
49
src/components/Switch.tsx
Normal file
49
src/components/Switch.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type manipulateBootnodeType = (
|
||||
bootnode: string,
|
||||
add: boolean,
|
||||
defaultBootnode: boolean,
|
||||
) => void
|
||||
|
||||
interface SwitchProps {
|
||||
bootnode: string
|
||||
alterBootnodes: manipulateBootnodeType
|
||||
defaultBootnode: boolean
|
||||
isChecked: boolean
|
||||
}
|
||||
|
||||
const Switch = ({
|
||||
bootnode,
|
||||
alterBootnodes,
|
||||
defaultBootnode,
|
||||
isChecked,
|
||||
}: SwitchProps) => {
|
||||
const [checked, setChecked] = useState<boolean>(isChecked)
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(isChecked)
|
||||
}, [isChecked])
|
||||
|
||||
return (
|
||||
<div className="flex w-1/12 ml-8" key={bootnode}>
|
||||
<label className="inline-flex relative items-center mr-5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={checked}
|
||||
readOnly
|
||||
/>
|
||||
<div
|
||||
onClick={() => {
|
||||
alterBootnodes(bootnode, !checked, defaultBootnode)
|
||||
setChecked(!checked)
|
||||
}}
|
||||
className="w-11 h-6 bg-background rounded-full peer peer-focus:ring-accent peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-muted after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-foreground"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
30
src/components/Title.tsx
Normal file
30
src/components/Title.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { ReactNode } from "react"
|
||||
|
||||
import { BsThreeDots } from "react-icons/bs"
|
||||
|
||||
interface TitleProps {
|
||||
children: ReactNode
|
||||
titleType?: "small" | "normal" | "large"
|
||||
showOptions?: boolean
|
||||
}
|
||||
|
||||
const Title = ({
|
||||
children,
|
||||
titleType = "normal",
|
||||
showOptions = false,
|
||||
className = "",
|
||||
}: TitleProps) => {
|
||||
const cName = className + (titleType === "small"
|
||||
? " text-sm text-muted-foreground"
|
||||
: titleType === "large"
|
||||
? " text-lg font-bold"
|
||||
: " text-base font-bold")
|
||||
return (
|
||||
<div className={"flex justify-between mb-4 ".concat(cName)}>
|
||||
<div className="capitalize">{children}</div>
|
||||
{showOptions && <BsThreeDots className="cursor-pointer" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Title }
|
8
src/components/index.tsx
Normal file
8
src/components/index.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
export { default as Logo } from "./Logo"
|
||||
export { default as light } from "./theme"
|
||||
export { MenuContent } from "./MenuContent"
|
||||
export { BraveModal } from "./BraveModal"
|
||||
export { IconWeb3 } from "./IconWeb3"
|
||||
export { Bootnodes } from "./Bootnodes"
|
||||
export { Title } from "./Title"
|
||||
export { Switch } from "./Switch"
|
150
src/components/theme.tsx
Normal file
150
src/components/theme.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
export const substrateGreen = {
|
||||
100: "#7E8D96",
|
||||
200: "#5CFFC8",
|
||||
300: "#18FFB2",
|
||||
400: "#16DB9A",
|
||||
500: "#11B37C",
|
||||
600: "#1A9A6C",
|
||||
}
|
||||
|
||||
const red = {
|
||||
50: "#ffebee",
|
||||
100: "#ffcdd2",
|
||||
200: "#ef9a9a",
|
||||
300: "#e57373",
|
||||
400: "#ef5350",
|
||||
500: "#f44336",
|
||||
600: "#e53935",
|
||||
700: "#d32f2f",
|
||||
800: "#c62828",
|
||||
900: "#b71c1c",
|
||||
A100: "#ff8a80",
|
||||
A200: "#ff5252",
|
||||
A400: "#ff1744",
|
||||
A700: "#d50000",
|
||||
}
|
||||
|
||||
const grey = {
|
||||
50: "#fafafa",
|
||||
100: "#f5f5f5",
|
||||
200: "#eeeeee",
|
||||
300: "#e0e0e0",
|
||||
400: "#bdbdbd",
|
||||
500: "#9e9e9e",
|
||||
600: "#757575",
|
||||
700: "#616161",
|
||||
800: "#424242",
|
||||
900: "#212121",
|
||||
A100: "#d5d5d5",
|
||||
A200: "#aaaaaa",
|
||||
A400: "#303030",
|
||||
A700: "#616161",
|
||||
}
|
||||
|
||||
const palette = {
|
||||
type: "light",
|
||||
common: {
|
||||
black: "black",
|
||||
white: "white",
|
||||
},
|
||||
background: {
|
||||
paper: "white",
|
||||
default: "white",
|
||||
},
|
||||
primary: {
|
||||
light: substrateGreen[100],
|
||||
main: substrateGreen[400],
|
||||
dark: substrateGreen[500],
|
||||
contrastText: "black",
|
||||
},
|
||||
secondary: {
|
||||
light: "#78B1D0",
|
||||
main: "#78B1D0",
|
||||
dark: "#78B1D0",
|
||||
contrastText: "#000000",
|
||||
},
|
||||
error: {
|
||||
light: red[100],
|
||||
main: "#FF3014",
|
||||
dark: red[500],
|
||||
contrastText: "black",
|
||||
},
|
||||
text: {
|
||||
primary: grey[800],
|
||||
secondary: grey[500],
|
||||
disabled: grey[300],
|
||||
hint: grey[700],
|
||||
},
|
||||
action: {
|
||||
active: substrateGreen[300],
|
||||
},
|
||||
divider: grey[200],
|
||||
}
|
||||
|
||||
const light = {
|
||||
typography: {
|
||||
fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, Segoe UI, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Ubuntu-Regular"',
|
||||
h1: {
|
||||
fontWeight: 500,
|
||||
fontSize: 30,
|
||||
lineHeight: "120%",
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 500,
|
||||
fontSize: 20,
|
||||
lineHeight: "120%",
|
||||
letterSpacing: -0.02,
|
||||
textTransform: "capitalize",
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600,
|
||||
fontSize: 17,
|
||||
lineHeight: "120%",
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 500,
|
||||
fontSize: 15,
|
||||
lineHeight: "120%",
|
||||
color: grey[800],
|
||||
},
|
||||
body1: {
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
lineHeight: "135%",
|
||||
letterSpacing: 0.15,
|
||||
},
|
||||
body2: {
|
||||
fontWeight: 400,
|
||||
fontSize: 11,
|
||||
lineHeight: "135%",
|
||||
},
|
||||
button: {
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
lineHeight: "120%",
|
||||
letterSpacing: 0.2,
|
||||
textTransform: "none",
|
||||
},
|
||||
subtitle1: {
|
||||
fontFamily: "SFMono-Regular, Consolas , Liberation Mono, Menlo, monospace",
|
||||
fontWeight: 400,
|
||||
fontSize: 20,
|
||||
lineHeight: "120%",
|
||||
},
|
||||
subtitle2: {
|
||||
fontFamily: "SFMono-Regular, Consolas , Liberation Mono, Menlo, monospace",
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
lineHeight: "135%",
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
overline: {
|
||||
fontSize: 11,
|
||||
lineHeight: "120%",
|
||||
letterSpacing: 0.7,
|
||||
},
|
||||
},
|
||||
palette: palette,
|
||||
}
|
||||
|
||||
export default light
|
56
src/components/ui/accordion.tsx
Normal file
56
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
139
src/components/ui/alert-dialog.tsx
Normal file
139
src/components/ui/alert-dialog.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
5
src/components/ui/aspect-ratio.tsx
Normal file
5
src/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
48
src/components/ui/avatar.tsx
Normal file
48
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-foreground hover:text-background",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-foreground hover:text-secondary",
|
||||
ghost: "text-primary hover:bg-accent hover:text-background",
|
||||
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
full: "h-10 w-full",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
64
src/components/ui/calendar.tsx
Normal file
64
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
|
||||
),
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: () => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t border-primary bg-card shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("px-6", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
260
src/components/ui/carousel.tsx
Normal file
260
src/components/ui/carousel.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
31
src/components/ui/checkbox.tsx
Normal file
31
src/components/ui/checkbox.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-accent data-[state=checked]:border-secondary data-[state=checked]:text-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
9
src/components/ui/collapsible.tsx
Normal file
9
src/components/ui/collapsible.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
153
src/components/ui/command.tsx
Normal file
153
src/components/ui/command.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
interface CommandDialogProps extends DialogProps {}
|
||||
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
198
src/components/ui/context-menu.tsx
Normal file
198
src/components/ui/context-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
))
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
))
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
))
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
120
src/components/ui/dialog.tsx
Normal file
120
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
116
src/components/ui/drawer.tsx
Normal file
116
src/components/ui/drawer.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
176
src/components/ui/form.tsx
Normal file
176
src/components/ui/form.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={className}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
27
src/components/ui/hover-card.tsx
Normal file
27
src/components/ui/hover-card.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
69
src/components/ui/input-otp.tsx
Normal file
69
src/components/ui/input-otp.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { Dot } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext)
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
28
src/components/ui/input.tsx
Normal file
28
src/components/ui/input.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
234
src/components/ui/menubar.tsx
Normal file
234
src/components/ui/menubar.tsx
Normal file
@ -0,0 +1,234 @@
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(
|
||||
(
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
),
|
||||
)
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
128
src/components/ui/navigation-menu.tsx
Normal file
128
src/components/ui/navigation-menu.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50",
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
117
src/components/ui/pagination.tsx
Normal file
117
src/components/ui/pagination.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
26
src/components/ui/progress.tsx
Normal file
26
src/components/ui/progress.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
49
src/components/ui/radio-group.tsx
Normal file
49
src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary bg-background text-accent ring-offset-background",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
<label className="ml-2" htmlFor={props.id}>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
43
src/components/ui/resizable.tsx
Normal file
43
src/components/ui/resizable.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
168
src/components/ui/select.tsx
Normal file
168
src/components/ui/select.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input",
|
||||
"bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground",
|
||||
"text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border",
|
||||
"bg-background text-primary shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2",
|
||||
"text-sm outline-none text-primary focus:bg-accent focus:text-accent-foreground",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
29
src/components/ui/separator.tsx
Normal file
29
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
136
src/components/ui/sheet.tsx
Normal file
136
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right: "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
26
src/components/ui/slider.tsx
Normal file
26
src/components/ui/slider.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
29
src/components/ui/sonner.tsx
Normal file
29
src/components/ui/sonner.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
27
src/components/ui/switch.tsx
Normal file
27
src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
53
src/components/ui/tabs.tsx
Normal file
53
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
27
src/components/ui/textarea.tsx
Normal file
27
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background",
|
||||
"px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
126
src/components/ui/toast.tsx
Normal file
126
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
33
src/components/ui/toaster.tsx
Normal file
33
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
59
src/components/ui/toggle-group.tsx
Normal file
59
src/components/ui/toggle-group.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
42
src/components/ui/toggle.tsx
Normal file
42
src/components/ui/toggle.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
sm: "h-9 px-2.5",
|
||||
lg: "h-11 px-5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
190
src/components/ui/use-toast.ts
Normal file
190
src/components/ui/use-toast.ts
Normal file
@ -0,0 +1,190 @@
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST": {
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
17
src/constants.ts
Normal file
17
src/constants.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const wellKnownGenesisHashByChainId: Record<string, string> = {
|
||||
casper_staging_testnet: "0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1",
|
||||
}
|
||||
|
||||
export const wellKnownChainIdByGenesisHash: Record<string, string> = {
|
||||
"0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1": "casper_staging_testnet",
|
||||
}
|
||||
|
||||
export const wellKnownPrefixByGenesisHash: Record<string, number> = {
|
||||
"0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1": 1996,
|
||||
}
|
||||
|
||||
export const wellKnownDecimalsByGenesisHash: Record<string, number> = {
|
||||
"0x07074eb5f47a6f4dd70430674e5174d5414bc055292b90392fb6f0a28c7524d1": 18,
|
||||
}
|
||||
|
||||
export const CHANNEL_ID = "ghost-extension"
|
7
src/containers/EmptyPage.tsx
Normal file
7
src/containers/EmptyPage.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { Layout2 } from "@/components/Layout2"
|
||||
|
||||
export const EmptyPage = () => {
|
||||
return (
|
||||
<Layout2 />
|
||||
)
|
||||
}
|
199
src/containers/Options.tsx
Normal file
199
src/containers/Options.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { FunctionComponent, useEffect, useMemo, useState } from "react"
|
||||
import { MdOutlineNetworkCell, MdOutlineOnlinePrediction } from "react-icons/md"
|
||||
import pckg from "../../package.json"
|
||||
import { FaGithub } from "react-icons/fa"
|
||||
import * as environment from "../environment"
|
||||
import { BraveModal, Logo, MenuContent, Bootnodes } from "../components"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useActiveChains } from "@/hooks/useActiveChains"
|
||||
import { NetworkTabProps } from "@/types"
|
||||
import { NetworkTab } from "./WalletPopup/components"
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
|
||||
type MenuItemTypes = "item" | "title" | "icon"
|
||||
|
||||
const item = [
|
||||
"group",
|
||||
"flex",
|
||||
"items-center",
|
||||
"text-base",
|
||||
"text-primary",
|
||||
"py-4",
|
||||
"px-6",
|
||||
"h-12",
|
||||
"overflow-hidden",
|
||||
"text-ellipsis",
|
||||
"whitespace-nowrap",
|
||||
"bg-background",
|
||||
"md:justify-start",
|
||||
"justify-center",
|
||||
]
|
||||
const itemInactive = [
|
||||
"bg-transparent",
|
||||
"transition",
|
||||
"duration-300",
|
||||
"ease-in-out",
|
||||
]
|
||||
const itemActive = [
|
||||
"cursor-default",
|
||||
]
|
||||
|
||||
const title = ["ml-4 font-inter font-medium text-primary"]
|
||||
const titleInactive = ["group-hover:text-accent"]
|
||||
const titleActive = ["text-accent", "cursor-default"]
|
||||
|
||||
const icon = [
|
||||
"w-6",
|
||||
"h-6",
|
||||
"md:w-4",
|
||||
"md:h-4",
|
||||
"text-primary",
|
||||
]
|
||||
const iconInactive = ["group-hover:text-accent"]
|
||||
const iconActive = ["cursor-default"]
|
||||
|
||||
const cName = (type: MenuItemTypes, menu = 0, reqMenu: number) => {
|
||||
let classes: string[] = []
|
||||
switch (type) {
|
||||
case "item":
|
||||
if (menu === reqMenu) {
|
||||
classes = [...item, ...itemActive]
|
||||
} else {
|
||||
classes = [...item, ...itemInactive]
|
||||
}
|
||||
break
|
||||
case "title":
|
||||
if (menu === reqMenu) {
|
||||
classes = [...title, ...titleActive]
|
||||
} else {
|
||||
classes = [...title, ...titleInactive]
|
||||
}
|
||||
break
|
||||
case "icon":
|
||||
if (menu === reqMenu) {
|
||||
classes = [...icon, ...iconActive]
|
||||
} else {
|
||||
classes = [...icon, ...iconInactive]
|
||||
}
|
||||
break
|
||||
}
|
||||
return classes.join(" ")
|
||||
}
|
||||
|
||||
const Networks: React.FC = () => {
|
||||
const chains = useActiveChains()
|
||||
const networks: NetworkTabProps[] = useMemo(
|
||||
() =>
|
||||
chains.map(({ chainName, isWellKnown, details }) => {
|
||||
return {
|
||||
isWellKnown,
|
||||
name: chainName,
|
||||
health: {
|
||||
isSyncing: details[0].isSyncing,
|
||||
peers: details[0].peers,
|
||||
status: "connected",
|
||||
bestBlockHeight: details[0].bestBlockHeight,
|
||||
},
|
||||
apps: details.map(({ url }) => ({
|
||||
name: url ?? "",
|
||||
url: url,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
[chains],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="container">
|
||||
<h2 className="pb-2 space-y-2 text-3xl font-semibold md:pb-4">
|
||||
Networks
|
||||
</h2>
|
||||
{networks.length ? (
|
||||
<Accordion type="multiple" className="w-full flex flex-col gap-4">
|
||||
{networks.map(({ name, health, apps, isWellKnown }, i) => (
|
||||
<NetworkTab
|
||||
key={i}
|
||||
name={name}
|
||||
health={health}
|
||||
isWellKnown={isWellKnown}
|
||||
apps={apps}
|
||||
/>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div>The extension isn't connected to any network.</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const Options: FunctionComponent = () => {
|
||||
const [menu, setMenu] = useState<0 | 1>(0)
|
||||
const [showModal, setShowModal] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
window.navigator?.brave?.isBrave().then(async (isBrave: any) => {
|
||||
const braveSetting = await environment.get({ type: "braveSetting" })
|
||||
setShowModal(isBrave && !braveSetting)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<BraveModal show={showModal} isOptions={true} />
|
||||
<div className="h-full flex">
|
||||
<div className="h-[full] bg-muted flex flex-col md:w-60 w-[100px]">
|
||||
<div className="md:px-6 md:pt-5 md:pb-2 p-2 pt-6">
|
||||
<div className="flex items-center">
|
||||
<div className="grow">
|
||||
<div className="flex justify-center items-center">
|
||||
<Logo cName="md:w-[80%] cursor-pointer fill-primary hover:fill-accent w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="relative pt-6">
|
||||
<li className="relative">
|
||||
<Link to="" className={cName("item", menu, 0)} onClick={() => setMenu(0)}>
|
||||
<MdOutlineNetworkCell className={cName("icon", menu, 0)} />
|
||||
<span className={`md:block hidden ${cName("title", menu, 0)}`} >Networks</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li className="relative">
|
||||
<Link to="" className={cName("item", menu, 1)} onClick={() => setMenu(1)}>
|
||||
<MdOutlineOnlinePrediction className={cName("icon", menu, 1)} />
|
||||
<span className={`md:block hidden ${cName("title", menu, 1)}`} >Bootnodes</span>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="w-full text-center flex-grow flex flex-col justify-end">
|
||||
<hr className="m-0" />
|
||||
<div className="block float-left px-6 py-4 cursor-pointer">
|
||||
<Link
|
||||
rel="noreferrer"
|
||||
to="https://git.ghostchain.io/ghostchain/ghost-extension-wallet"
|
||||
target="_blank"
|
||||
className="flex md:flex-row flex-col gap-4 items-center"
|
||||
>
|
||||
<FaGithub className="w-8 h-8" />
|
||||
<div className="block float-left text-xs text-left">
|
||||
<div className="md:block hidden text-primary">Ghost Wallet Git</div>
|
||||
<div className="text-accent">v {pckg.version}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[calc(100%-15rem)] h-[100vh] overflow-auto grow flex-grow">
|
||||
<MenuContent>
|
||||
{menu === 0 ? (
|
||||
<Networks />
|
||||
) : menu === 1 ? (
|
||||
<Bootnodes />
|
||||
) : null}
|
||||
</MenuContent>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
54
src/containers/WalletPopup.tsx
Normal file
54
src/containers/WalletPopup.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { HashRouter, Routes, Route } from "react-router-dom"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
import { ProtectedRoute } from "./WalletPopup/components"
|
||||
import { KeyringProvider } from "./WalletPopup/hooks"
|
||||
import { EmptyPage } from "./EmptyPage"
|
||||
|
||||
// Dynamic Imports for code splitting
|
||||
const UnlockKeyring = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.UnlockKeyring })))
|
||||
const SignRequest = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.SignRequest })))
|
||||
const Settings = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.Settings })))
|
||||
const ChangePassword = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.ChangePassword })))
|
||||
const Welcome = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.Welcome })))
|
||||
const AddAccount = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.AddAccount })))
|
||||
const SwitchAccount = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.SwitchAccount })))
|
||||
const ImportAccounts = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.ImportAccounts })))
|
||||
const AccountDetails = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.AccountDetails })))
|
||||
const AddChainByUser = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.AddChainByUser })))
|
||||
const Accounts = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.Accounts })))
|
||||
const DeleteWallet = lazy(() => import("./WalletPopup/pages").then(module => ({ default: module.DeleteWallet })))
|
||||
const Options = lazy(() => import("./Options").then(module => ({ default: module.Options })))
|
||||
const CreatePassword = lazy(() => import("./WalletPopup/pages/CreatePassword").then(module => ({ default: module.CreatePassword })))
|
||||
const Networks = lazy(() => import("./WalletPopup/pages/Networks").then(module => ({ default: module.Networks })))
|
||||
|
||||
export const WalletPopup = () => (
|
||||
<HashRouter>
|
||||
<KeyringProvider>
|
||||
<Suspense fallback={<EmptyPage />}>
|
||||
<Routes>
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Accounts />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/change-password" element={<ChangePassword />} />
|
||||
<Route path="/delete-wallet" element={<DeleteWallet />} />
|
||||
|
||||
<Route path="/accounts/:accountId" element={<AccountDetails />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
<Route path="/accounts/add" element={<AddAccount />} />
|
||||
<Route path="/accounts/switch" element={<SwitchAccount />} />
|
||||
<Route path="/accounts/import" element={<ImportAccounts />} />
|
||||
|
||||
<Route path="/sign-request/:signRequestId" element={<SignRequest />} />
|
||||
<Route path="/add-chain-by-user" element={<AddChainByUser />} />
|
||||
</Route>
|
||||
<Route path="/options" element={<Options />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
<Route path="/networks" element={<Networks />} />
|
||||
<Route path="/create-password" element={<CreatePassword />} />
|
||||
<Route path="/unlock-keyring" element={<UnlockKeyring />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</KeyringProvider>
|
||||
</HashRouter>
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user