diff --git a/package.json b/package.json index 80832fd..a891489 100644 --- a/package.json +++ b/package.json @@ -8,28 +8,32 @@ }, "license": "MIT", "dependencies": { - "bech32": "^1.1.3", - "bitcoinjs-lib": "^5.1.6", - "bn.js": "^5.0.0", - "buffer": "^5.4.3", - "classnames": "^2.2.6", + "@radix-ui/react-dialog": "latest", + "@radix-ui/react-slot": "latest", + "bech32": "latest", + "bitcoinjs-lib": "latest", + "bn.js": "latest", + "buffer": "latest", + "class-variance-authority": "latest", + "clsx": "latest", "coininfo": "https://github.com/cryptocoinjs/coininfo#dc3e6cc59e593ee7dbeb7c993485706e72d32743", - "date-fns": "^2.1.0", - "eslint": "^5.16.0", - "lodash": "^4.17.15", - "react": "^16.9.0", - "react-dom": "^16.9.0", - "react-ga": "^2.6.0", - "react-qr-reader": "^2.2.1", - "react-scripts": "3.1.1", - "safe-buffer": "^5.2.0", - "sass": "^1.37.5", - "secp256k1": "^3.7.1", - "serve": "^11.1.0" + "date-fns": "latest", + "lodash": "latest", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "react-ga": "latest", + "react-qr-reader": "latest", + "react-scripts": "latest", + "safe-buffer": "latest", + "secp256k1": "latest", + "serve": "latest", + "tailwind-merge": "latest", + "tailwindcss-animate": "latest" }, "scripts": { - "start": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts start", - "build": "export NODE_OPTIONS=--openssl-legacy-provider && react-scripts build", + "start": "react-scripts start", + "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "deploy": "npm run build && cd build && now" @@ -49,7 +53,10 @@ "bitcoin" ], "devDependencies": { - "node": "^18.9.0", - "eslint": "^6.1.0" + "autoprefixer": "latest", + "eslint": "latest", + "node": "latest", + "postcss": "latest", + "tailwindcss": "latest" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/src/app.js b/src/app.js index dddb0bd..d546de3 100644 --- a/src/app.js +++ b/src/app.js @@ -1,14 +1,7 @@ // Core Libs & Utils -import React, { PureComponent } from 'react'; +import React, { useEffect, useState } from 'react'; import QrReader from 'react-qr-reader'; -import cx from 'classnames'; - -// Assets -import boltImage from './assets/images/bolt.png'; -import arrowImage from './assets/images/arrow.svg'; -import closeImage from './assets/images/close.svg'; -import qrcodeImage from './assets/images/qrcode.png'; -import githubImage from './assets/images/github.svg'; +import { Github, QrCode, RotateCcw, Search, Zap } from 'lucide-react'; // Utils import { formatDetailsKey } from './utils/keys'; @@ -31,630 +24,466 @@ import { LNURL_TAG_KEY, } from './constants/keys'; -// Styles -import './assets/styles/main.scss'; +import { Alert, AlertDescription, AlertTitle } from './components/ui/alert'; +import { Badge } from './components/ui/badge'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from './components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './components/ui/dialog'; +import { Button } from './components/ui/button'; +import { Input } from './components/ui/input'; const INITIAL_STATE = { text: '', - error: {}, - hasError: false, + error: null, decodedInvoice: {}, + isLNURL: false, isLNAddress: false, isQRCodeOpened: false, isInvoiceLoaded: false, - isBitcoinAddrOpened: false, }; -export class App extends PureComponent { - state = INITIAL_STATE; +export function App() { + const [text, setText] = useState(INITIAL_STATE.text); + const [error, setError] = useState(INITIAL_STATE.error); + const [decodedInvoice, setDecodedInvoice] = useState(INITIAL_STATE.decodedInvoice); + const [isLNURL, setIsLNURL] = useState(INITIAL_STATE.isLNURL); + const [isLNAddress, setIsLNAddress] = useState(INITIAL_STATE.isLNAddress); + const [isQRCodeOpened, setIsQRCodeOpened] = useState(INITIAL_STATE.isQRCodeOpened); + const [isInvoiceLoaded, setIsInvoiceLoaded] = useState(INITIAL_STATE.isInvoiceLoaded); - componentDidMount() { + useEffect(() => { const invoiceOnURLParam = window.location.pathname; - - // Remove first `/` from pathname const cleanInvoice = invoiceOnURLParam.split('/')[1]; if (cleanInvoice && cleanInvoice !== '') { - this.setState(() => ({ text: cleanInvoice })); - this.getInvoiceDetails(cleanInvoice); + setText(cleanInvoice); + getInvoiceDetails(cleanInvoice); } - } + }, []); - clearInvoiceDetails = () => { - // Reset URL address + const clearInvoiceDetails = () => { const currentOrigin = window.location.origin; window.history.pushState({}, null, `${currentOrigin}`); - this.setState(() => ({ - ...INITIAL_STATE, - })); + setText(INITIAL_STATE.text); + setError(INITIAL_STATE.error); + setDecodedInvoice(INITIAL_STATE.decodedInvoice); + setIsLNURL(INITIAL_STATE.isLNURL); + setIsLNAddress(INITIAL_STATE.isLNAddress); + setIsQRCodeOpened(INITIAL_STATE.isQRCodeOpened); + setIsInvoiceLoaded(INITIAL_STATE.isInvoiceLoaded); }; - getInvoiceDetails = async (text) => { - // If this returns null is because there is no invoice to parse - if (!text) { - return this.setState(() => ({ - hasError: true, - decodedInvoice: {}, - isInvoiceLoaded: false, - error: { message: 'Please enter a valid request or address and try again.'}, - })); + const setErrorState = (message) => { + setError({ message }); + setDecodedInvoice({}); + setIsInvoiceLoaded(false); + }; + + const getInvoiceDetails = async (textValue) => { + if (!textValue) { + return setErrorState('Please enter a valid request or address and try again.'); } try { let response; - const parsedInvoiceResponse = await parseInvoice(text); + const parsedInvoiceResponse = await parseInvoice(textValue); - // If this returns null is because there is no invoice to parse if (!parsedInvoiceResponse) { - return this.setState(() => ({ - hasError: true, - decodedInvoice: {}, - isInvoiceLoaded: false, - error: { message: 'Please enter a valid request or address and try again.'}, - })); + return setErrorState('Please enter a valid request or address and try again.'); } - const { isLNURL, data, error, isLNAddress } = parsedInvoiceResponse; - - // If an error comes back from a nested operation in parsing it must - // propagate back to the end user - if (error && error.length > 0) { - return this.setState(() => ({ - hasError: true, - decodedInvoice: {}, - isInvoiceLoaded: false, - error: { message: error }, - })); + const { isLNURL: parsedIsLNURL, data, error: parseError, isLNAddress: parsedIsLNAddress } = parsedInvoiceResponse; + + if (parseError && parseError.length > 0) { + return setErrorState(parseError); } - // If data is null it means the parser could not understand the invoice if (!data) { - return this.setState(() => ({ - hasError: true, - decodedInvoice: {}, - isInvoiceLoaded: false, - error: { message: 'Could not parse/understand this invoice or request. Please try again.'}, - })); + return setErrorState('Could not parse/understand this invoice or request. Please try again.'); } - // Handle LNURLs differently - if (isLNURL) { - // If this is a Lightning Address, the contents have already been fetched - if (isLNAddress) { + if (parsedIsLNURL) { + if (parsedIsLNAddress) { response = data; } else { - // Otherwise this is an LNURL ready to be fetched response = await data; } } else { - // Handle normal invoices response = data; } if (response) { - // On successful response, set the request content on the addressbar - // if there isn't one already in there from before (user-entered) const currentUrl = window.location; const currentOrigin = window.location.origin; const currentPathname = window.location.pathname; const hasPathnameAlready = currentPathname && currentPathname !== ''; - // If there's a pathname already, we can just remove it and let the - // new pathname be entered if (hasPathnameAlready) { window.history.pushState({}, null, `${currentOrigin}`); } - window.history.pushState({}, null, `${currentUrl}${text}`); + window.history.pushState({}, null, `${currentUrl}${textValue}`); - this.setState(() => ({ - isLNURL, - error: {}, - isLNAddress, - hasError: false, - isInvoiceLoaded: true, - decodedInvoice: response, - })); + setIsLNURL(parsedIsLNURL); + setError(null); + setIsLNAddress(parsedIsLNAddress); + setIsInvoiceLoaded(true); + setDecodedInvoice(response); } - } catch(error) { - this.setState(() => ({ - error: error, - hasError: true, - decodedInvoice: {}, - isInvoiceLoaded: false, - })); + } catch (caughtError) { + setError(caughtError); + setDecodedInvoice({}); + setIsInvoiceLoaded(false); } - } - - handleChange = (event) => { - const { target: { value: text } } = event; - - this.setState(() => ({ - text, - error: {}, - hasError: false, - })); - } + }; - handleKeyPress = (event) => { - const { text } = this.state; + const handleChange = (event) => { + const { target: { value } } = event; + setText(value); + setError(null); + }; + const handleKeyPress = (event) => { if (event.key === 'Enter') { - this.getInvoiceDetails(text); + getInvoiceDetails(text); } - } - - handleQRCode = () => this.setState(prevState => ({ - isQRCodeOpened: !prevState.isQRCodeOpened - })) - - renderErrorDetails = () => { - const { hasError, error } = this.state; - - if (!hasError) return null; - - return ( -
-
-
- {error.message} -
-
-
- ); - } + }; - renderInput = () => { - const { text } = this.state; + const handleScan = (value) => { + if (Object.is(value, null)) return; - return ( -
- Lightning - -
- ); - } + let nextText = value; + if (value.includes('lightning')) { + nextText = value.split('lightning:')[1]; + } - renderInvoiceDetails = () => { - const { decodedInvoice, isInvoiceLoaded } = this.state; - const invoiceContainerClassnames = cx( - 'invoice', - { 'invoice--opened': isInvoiceLoaded }, - ); + getInvoiceDetails(nextText); + setIsQRCodeOpened(false); + setText(nextText); + }; - const invoiceDetails = Object.keys(decodedInvoice) - .map((key) => { - switch (key) { - case COMPLETE_KEY: - return null; - case TAGS_KEY: - return this.renderInvoiceInnerItem(key); - case TIMESTAMP_STRING_KEY: - return this.renderInvoiceItem( - key, - TIMESTAMP_STRING_KEY, - ); - default: - return this.renderInvoiceItem(key); - } - }); + const handleError = (qrError) => { + setError(qrError); + setIsInvoiceLoaded(false); + setIsQRCodeOpened(false); + }; - return !isInvoiceLoaded ? null : ( -
- {invoiceDetails} + const renderNestedItem = (label, value) => ( +
+
+ {formatDetailsKey(label)}
- ); - } +
{value}
+
+ ); - renderInvoiceInnerItem = (key) => { - const { decodedInvoice } = this.state; + const renderInvoiceInnerItems = (key) => { const tags = decodedInvoice[key]; - const renderTag = (tag) => ( - typeof tag.data !== 'string' && - typeof tag.data !== 'number' - ) ? renderNestedTag(tag) : renderNormalTag(tag); - - const renderNestedItem = (label, value) => ( -
-
- {formatDetailsKey(label)} -
-
- {value} -
-
- ); - const renderNestedTag = (tag) => ( -
-
+
+
{formatDetailsKey(tag.tagName)}
-
- {/* Strings */} +
{typeof tag.data === 'string' && ( -
- {tag.data} -
+
{tag.data}
)} - {/* Array of Objects */} - {Array.isArray(tag.data) && tag.data.map((item) => ( - <> + {Array.isArray(tag.data) && tag.data.map((item, index) => ( +
{Object.keys(item).map((label) => renderNestedItem(label, item[label]))} - +
))} - {/* Objects */} - {( - !Array.isArray(tag.data) && ( - (typeof tag.data !== 'string') || (typeof tag.data !== 'number')) - ) && ( - <> + {!Array.isArray(tag.data) && tag.data && typeof tag.data === 'object' && ( +
{Object.keys(tag.data).map((label) => renderNestedItem(label, tag.data[label]))} - +
)}
); const renderNormalTag = (tag) => ( -
-
+
+
{formatDetailsKey(tag.tagName)}
-
- {`${tag.data || '--'}`} -
+
{`${tag.data || '--'}`}
- ) - - return tags.map((tag) => renderTag(tag)); - } + ); - renderInvoiceItem = (key, valuePropFormat) => { - const { decodedInvoice } = this.state; + return tags.map((tag) => ( + typeof tag.data !== 'string' && typeof tag.data !== 'number' + ? renderNestedTag(tag) + : renderNormalTag(tag) + )); + }; + const renderInvoiceItem = (key, valuePropFormat) => { let value = `${decodedInvoice[key]}`; - if ( - valuePropFormat && - valuePropFormat === TIMESTAMP_STRING_KEY - ) { - // TODO: this breaks - // value = `${formatTimestamp(decodedInvoice[key])}`; + if (valuePropFormat && valuePropFormat === TIMESTAMP_STRING_KEY) { + value = `${decodedInvoice[key]}`; } return ( -
-
+
+
{formatDetailsKey(key)}
-
- {value} -
+
{value}
); - } + }; - renderLogo = () => ( -
-
- {APP_NAME} -
-
- {APP_TAGLINE} - {APP_SUBTAGLINE} -
-
- ); + const invoiceDetails = !isInvoiceLoaded || isLNURL + ? null + : Object.keys(decodedInvoice).flatMap((key) => { + switch (key) { + case COMPLETE_KEY: + return []; + case TAGS_KEY: + return renderInvoiceInnerItems(key); + case TIMESTAMP_STRING_KEY: + return [renderInvoiceItem(key, TIMESTAMP_STRING_KEY)]; + default: + return [renderInvoiceItem(key)]; + } + }); - renderLNURLDetails = () => { - const { decodedInvoice, isInvoiceLoaded } = this.state; - const invoiceContainerClassnames = cx( - 'invoice', - { 'invoice--opened': isInvoiceLoaded }, - ); + const lnurlDetails = !isInvoiceLoaded || !isLNURL + ? null + : Object.keys(decodedInvoice).flatMap((key) => { + if (typeof decodedInvoice[key] === 'object') { + return []; + } - let requestContents = decodedInvoice; + if (key === 'status') { + return []; + } - return !isInvoiceLoaded ? null : ( -
- {Object.keys(requestContents).map((key) => { - let text = decodedInvoice[key]; - - if (typeof decodedInvoice[key] === 'object') { - return <>; - } - - if (key === 'status') { - return <> - } + if (key === LNURL_TAG_KEY) { + const textLabel = decodedInvoice[key]; + return [ +
+
+ {formatDetailsKey(key)} +
+ +
, + ]; + } + + if (key === CALLBACK_KEY) { + return [ +
+
+ {formatDetailsKey(key)} +
+ +
, + ]; + } - if (key === LNURL_TAG_KEY) { - switch (key) { - case 'payRequest': - text = 'LNURL Pay (payRequest)' - break; - case 'withdrawRequest': - text = 'LNURL Withdraw (withdrawRequest)' - break; - default: - break; - } + if (key === LNURL_METADATA_KEY) { + const splitMetadata = JSON.parse(decodedInvoice[key]); + return splitMetadata.map((arrOfData, index) => { + if (arrOfData[0] === 'text/plain') { return ( -
-
- {formatDetailsKey(key)} -
-
- - {text} - +
+
+ Description
+
{arrOfData[1]}
- ) + ); } - if (key === CALLBACK_KEY) { + if (arrOfData[0] === 'text/identifier') { return ( -
-
- {formatDetailsKey(key)} -
-
- - {decodedInvoice[key]} - +
+
+ Lightning Address
+
{arrOfData[1]}
- ) - } - - if (key === LNURL_METADATA_KEY) { - const splitMetadata = JSON.parse(decodedInvoice[key]); - - // eslint-disable-next-line array-callback-return - const toRender = splitMetadata.map((arrOfData) => { - if (arrOfData[0] === 'text/plain') { - return ( -
-
- Description -
-
- {arrOfData[1]} -
-
- ) - } - - if (arrOfData[0] === 'text/identifier') { - return ( -
-
- Lightning Address -
-
- {arrOfData[1]} -
-
- ) - } - - if (arrOfData[0] === 'image/png;base64') { - return ( -
-
- Image -
-
- Imager -
-
- ); - } - }); - - return toRender; + ); } - return ( -
-
- {formatDetailsKey(key)} -
-
- {decodedInvoice[key]} + if (arrOfData[0] === 'image/png;base64') { + return ( +
+
+ Image +
+ Image
-
- ); - })} -
- ); - } - - renderSubmit = () => { - const { isInvoiceLoaded, text } = this.state; - const submitClassnames = cx( - 'submit', - { 'submit__close': isInvoiceLoaded }, - ); + ); + } - const onClick = () => { - if (isInvoiceLoaded) { - this.clearInvoiceDetails(); - } else { - this.getInvoiceDetails(text); + return null; + }).filter(Boolean); } - } - - return ( -
- Submit -
- ); - } - - renderOptions = () => { - const { isInvoiceLoaded } = this.state; - const optionsClassnames = cx( - 'options', - { 'options--hide': isInvoiceLoaded }, - ); - return ( -
-
- - GitHub - -
-
- ); - } - - renderCamera = () => { - const { isQRCodeOpened, isInvoiceLoaded } = this.state; - - const styleQRWrapper = cx({ - 'qrcode' : true, - 'qrcode--opened': isQRCodeOpened, + return [ +
+
+ {formatDetailsKey(key)} +
+
{decodedInvoice[key]}
+
, + ]; }); - const styleQRContainer = cx( - 'qrcode__container', - { 'qrcode__container--opened': isQRCodeOpened }, - ); - const styleImgQR = cx( - 'qrcode__img', - { 'qrcode__img--opened': isQRCodeOpened }, - ); - - const qrReaderStyles = { - width: '100%', - border: '2pt solid #000000', - }; - - const srcImage = isQRCodeOpened ? closeImage : qrcodeImage; - - const handleScan = (value) => { - if (Object.is(value, null)) return; - let text = value; - if (value.includes('lightning')) { - text = value.split('lightning:')[1]; - } - - this.getInvoiceDetails(text); - this.setState(() => ({ - isQRCodeOpened: false, - text, - })); - } + const hasError = Boolean(error); - const handleError = (error) => this.setState(() => ({ - isInvoiceLoaded: false, - hasError: true, - error, - isQRCodeOpened: false - })); - - return isInvoiceLoaded ? null : ( -
- {isQRCodeOpened && ( -
+ return ( +
+
+
+
+
+ +
+
+

{APP_NAME}

+

+ {APP_TAGLINE}{' '} + + {APP_SUBTAGLINE} + +

+
+
+
+ + {isInvoiceLoaded && ( + Loaded + )} + {isLNURL && ( + LNURL + )} + {isLNAddress && ( + Lightning Address + )} +
+
+ + + + Decode an invoice + Paste a Lightning invoice, LNURL, or Lightning Address to inspect it. + + +
+
+ + +
+
+ + {!isInvoiceLoaded && ( + + + + + + + Scan a Lightning QR + Point your camera at a Lightning invoice QR code. + +
+ +
+
+
+ )} +
+
+
+
+ + {isInvoiceLoaded && ( + + +
+ {isLNURL ? 'LNURL Details' : 'Invoice Details'} + + {isLNURL + ? 'Fetched metadata and fields for the LNURL request.' + : 'Parsed invoice fields and tags.'} + +
+
+ + {isLNURL ? lnurlDetails : invoiceDetails} + +
)} -
- QRCode - {!isQRCodeOpened ? null : ( - - )} -
-
- ); - } - - render() { - const { isLNURL, isInvoiceLoaded, hasError } = this.state; - - const appClasses = cx( - 'app', - { 'app--opened': isInvoiceLoaded }, - ); - const appColumnClasses = cx( - 'app__column', - { - 'app__column--invoice-loaded': isInvoiceLoaded, - 'app__column--error': hasError, - }, - ); - const appSubmitClasses = cx( - 'app__submit', - { 'app__submit--invoice-loaded': isInvoiceLoaded }, - ); - return ( -
- {this.renderOptions()} - {this.renderLogo()} -
- {this.renderInput()} -
- {this.renderSubmit()} - {this.renderCamera()} -
-
-
- {isLNURL ? this.renderLNURLDetails() : this.renderInvoiceDetails()} - {this.renderErrorDetails()} -
+ {hasError && ( + + Unable to decode + {error.message || String(error)} + + )}
- ); - } +
+ ); } diff --git a/src/components/ui/alert.jsx b/src/components/ui/alert.jsx new file mode 100644 index 0000000..ae130c9 --- /dev/null +++ b/src/components/ui/alert.jsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva } 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', + { + 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(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx new file mode 100644 index 0000000..2ddb43a --- /dev/null +++ b/src/components/ui/badge.jsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { cva } from 'class-variance-authority'; + +import { cn } from '../../lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border border-transparent 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: 'bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'border-border text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +); + +function Badge({ className, variant, ...props }) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx new file mode 100644 index 0000000..9aba9fd --- /dev/null +++ b/src/components/ui/button.jsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { cva } from 'class-variance-authority'; + +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: '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', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/src/components/ui/card.jsx b/src/components/ui/card.jsx new file mode 100644 index 0000000..0c32657 --- /dev/null +++ b/src/components/ui/card.jsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx new file mode 100644 index 0000000..ce14526 --- /dev/null +++ b/src/components/ui/dialog.jsx @@ -0,0 +1,87 @@ +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(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ className, ...props }) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx new file mode 100644 index 0000000..7db8ffc --- /dev/null +++ b/src/components/ui/input.jsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => ( + +)); +Input.displayName = 'Input'; + +export { Input }; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..36a8865 --- /dev/null +++ b/src/index.css @@ -0,0 +1,36 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 224 71% 4%; + --foreground: 210 40% 98%; + --card: 222 47% 11%; + --card-foreground: 210 40% 98%; + --popover: 222 47% 11%; + --popover-foreground: 210 40% 98%; + --primary: 45 93% 58%; + --primary-foreground: 222 47% 11%; + --secondary: 217 33% 17%; + --secondary-foreground: 210 40% 98%; + --muted: 217 33% 17%; + --muted-foreground: 215 20% 65%; + --accent: 217 33% 17%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62% 51%; + --destructive-foreground: 210 40% 98%; + --border: 217 33% 17%; + --input: 217 33% 17%; + --ring: 45 93% 58%; + --radius: 0.75rem; + } + + body { + @apply bg-background text-foreground; + } + + * { + @apply border-border; + } +} diff --git a/src/index.js b/src/index.js index 19ac71e..af48d55 100644 --- a/src/index.js +++ b/src/index.js @@ -1,13 +1,13 @@ // Core Libs import React from 'react'; -import ReactDOM from 'react-dom'; +import { createRoot } from 'react-dom/client'; + +import './index.css'; // Main Component import { App } from './app'; -// DOM Render -ReactDOM.render( - , - document.getElementById('root'), -); +const container = document.getElementById('root'); +const root = createRoot(container); +root.render(); diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 0000000..0ae73e1 --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..1926a7e --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,49 @@ +module.exports = { + darkMode: ['class'], + content: ['./src/**/*.{js,jsx}', './public/index.html'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +};