├── .env ├── .gitignore ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── favicon-dark │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest ├── favicon-light │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ └── site.webmanifest └── images │ └── site-screenshot.png ├── src ├── App.tsx ├── design_system │ ├── Button.tsx │ ├── Message.tsx │ └── SyntaxHighlighter.tsx ├── hooks │ └── useVoices.ts ├── index.css ├── lib │ ├── api.ts │ ├── config.ts │ ├── storage.ts │ └── voice.ts ├── main.tsx └── vite-env.d.ts ├── tailwind.config.cjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env: -------------------------------------------------------------------------------- 1 | VITE_IS_LOCAL_SETUP_REQUIRED="0" 2 | VITE_API_HOST="https://chatgpt-voice-server.herokuapp.com" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 |

5 | 6 |
7 | 8 |

9 | Have a conversation with ChatGPT. Casually 🔈 🤖 ⚡️ 10 |

11 | 12 |

13 | Website | Backend 14 |

17 | Website screenshot 18 |

19 | 20 | ## Features 21 | 22 | - 📣 Conversation with ChatGPT, with full context, in a neat UI. 23 | - ⚙️ Customizable voice and speech rate. 24 | 25 |

26 | Voice and speech rate are configurable 27 |

28 | 29 | ## Development 30 | 31 | You can also set up this project locally to play with it, contribute to it or hack it to your heart's content. Simply clone it, install dependencies then start the dev server. 32 | 33 | ```bash 34 | git clone https://github.com/sonngdev/chatgpt-voice.git 35 | cd chatgpt-voice 36 | npm install 37 | npm run dev 38 | ``` 39 | 40 | [A backend server](https://github.com/sonngdev/chatgpt-server) accompanies this frontend client. See its `README` file for installation guide. 41 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ChatGPT With Voice 37 | 38 | 39 | 40 |

41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatgpt-voice", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.0.2", 13 | "@radix-ui/react-select": "^1.1.2", 14 | "@radix-ui/react-slider": "^1.1.0", 15 | "@radix-ui/react-tooltip": "^1.0.2", 16 | "react": "^18.2.0", 17 | "react-device-detect": "^2.2.2", 18 | "react-dom": "^18.2.0", 19 | "react-feather": "^2.0.10", 20 | "react-speech-recognition": "^3.10.0", 21 | "react-syntax-highlighter": "^15.5.0", 22 | "regenerator-runtime": "^0.13.11" 23 | }, 24 | "devDependencies": { 25 | "@types/react": "^18.0.26", 26 | "@types/react-dom": "^18.0.9", 27 | "@types/react-speech-recognition": "^3.9.0", 28 | "@types/react-syntax-highlighter": "^15.5.6", 29 | "@vitejs/plugin-react": "^3.0.0", 30 | "autoprefixer": "^10.4.13", 31 | "postcss": "^8.4.21", 32 | "tailwindcss": "^3.2.4", 33 | "typescript": "^4.9.3", 34 | "vite": "^4.0.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon-dark/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon-dark/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon-dark/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-dark/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-dark/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-dark/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-dark/favicon.ico -------------------------------------------------------------------------------- /public/favicon-dark/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /public/favicon-light/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/favicon-light/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/favicon-light/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-light/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-light/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-light/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/favicon-light/favicon.ico -------------------------------------------------------------------------------- /public/favicon-light/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /public/images/site-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonngdev/chatgpt-voice/a6d19826d47f8ff67181b4a5a3c17194cbaa6506/public/images/site-screenshot.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Fragment, 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import { useSpeechRecognition } from 'react-speech-recognition'; 10 | import { 11 | GitHub, 12 | Settings, 13 | FilePlus, 14 | Mic, 15 | Activity, 16 | Loader, 17 | AlertTriangle, 18 | X, 19 | ChevronDown, 20 | ChevronUp, 21 | Check, 22 | Headphones, 23 | Info, 24 | } from 'react-feather'; 25 | import * as Tooltip from '@radix-ui/react-tooltip'; 26 | import * as Dialog from '@radix-ui/react-dialog'; 27 | import * as Slider from '@radix-ui/react-slider'; 28 | import * as Select from '@radix-ui/react-select'; 29 | import { isDesktop, isMobile } from 'react-device-detect'; 30 | 31 | import Button from './design_system/Button'; 32 | import SyntaxHighlighter from './design_system/SyntaxHighlighter'; 33 | import Message from './design_system/Message'; 34 | import API from './lib/api'; 35 | import Config from './lib/config'; 36 | import Storage from './lib/storage'; 37 | import Voice from './lib/voice'; 38 | import useVoices from './hooks/useVoices'; 39 | 40 | interface CreateChatGPTMessageResponse { 41 | answer: string; 42 | messageId: string; 43 | } 44 | 45 | interface Message { 46 | type: 'prompt' | 'response'; 47 | text: string; 48 | } 49 | 50 | interface VoiceMappings { 51 | [group: string]: SpeechSynthesisVoice[]; 52 | } 53 | 54 | enum State { 55 | IDLE, 56 | LISTENING, 57 | PROCESSING, 58 | } 59 | 60 | const savedData = Storage.load(); 61 | 62 | function App() { 63 | const { 64 | browserSupportsSpeechRecognition, 65 | isMicrophoneAvailable, 66 | transcript, 67 | listening, 68 | finalTranscript, 69 | } = useSpeechRecognition(); 70 | 71 | const initialMessages: Message[] = [ 72 | { type: 'response', text: 'Try speaking to the microphone.' }, 73 | ]; 74 | const defaultSettingsRef = useRef({ 75 | host: 'http://localhost', 76 | port: 8000, 77 | voiceURI: '', 78 | voiceSpeed: 1, 79 | }); 80 | const [state, setState] = useState(State.IDLE); 81 | const [messages, setMessages] = useState(initialMessages); 82 | const [settings, setSettings] = useState({ 83 | host: (savedData?.host as string) ?? defaultSettingsRef.current.host, 84 | port: (savedData?.port as number) ?? defaultSettingsRef.current.port, 85 | voiceURI: 86 | (savedData?.voiceURI as string) ?? defaultSettingsRef.current.voiceURI, 87 | voiceSpeed: 88 | (savedData?.voiceSpeed as number) ?? 89 | defaultSettingsRef.current.voiceSpeed, 90 | }); 91 | const [isModalVisible, setIsModalVisible] = useState(false); 92 | const [isTooltipVisible, setIsTooltipVisible] = useState( 93 | Config.IS_LOCAL_SETUP_REQUIRED, 94 | ); 95 | const { voices, defaultVoice } = useVoices(); 96 | const abortRef = useRef(null); 97 | const conversationRef = useRef({ currentMessageId: '' }); 98 | const bottomDivRef = useRef(null); 99 | 100 | const availableVoices = useMemo(() => { 101 | const englishTypes = new Map(); 102 | englishTypes.set('en-AU', 'English (Australia)'); 103 | englishTypes.set('en-CA', 'English (Canada)'); 104 | englishTypes.set('en-GB', 'English (United Kingdom)'); 105 | englishTypes.set('en-IE', 'English (Ireland)'); 106 | englishTypes.set('en-IN', 'English (India)'); 107 | englishTypes.set('en-NZ', 'English (New Zealand)'); 108 | englishTypes.set('en-US', 'English (United State)'); 109 | 110 | const localEnglishVoices = voices.filter( 111 | (voice) => voice.localService && voice.lang.startsWith('en-'), 112 | ); 113 | 114 | const result: VoiceMappings = {}; 115 | for (let voice of localEnglishVoices) { 116 | const label = englishTypes.get(voice.lang); 117 | if (typeof label !== 'string') { 118 | continue; 119 | } 120 | if (!result[label]) { 121 | result[label] = []; 122 | } 123 | result[label].push(voice); 124 | } 125 | return result; 126 | }, [voices]); 127 | 128 | const selectedVoice = useMemo(() => { 129 | return voices.find((voice) => voice.voiceURI === settings.voiceURI); 130 | }, [voices, settings.voiceURI]); 131 | 132 | const recognizeSpeech = () => { 133 | if (state === State.IDLE) { 134 | Voice.enableAutoplay(); 135 | Voice.startListening(); 136 | } else if (state === State.LISTENING) { 137 | Voice.stopListening(); 138 | } 139 | }; 140 | 141 | const speak = useCallback( 142 | (text: string) => { 143 | Voice.speak(text, { voice: selectedVoice, rate: settings.voiceSpeed }); 144 | }, 145 | [selectedVoice, settings.voiceSpeed], 146 | ); 147 | 148 | const resetConversation = () => { 149 | setState(State.IDLE); 150 | setMessages(initialMessages); 151 | conversationRef.current = { currentMessageId: '' }; 152 | 153 | Voice.idle(); 154 | abortRef.current?.abort(); 155 | }; 156 | 157 | const handleModalOpenChange = (isOpen: boolean) => { 158 | setIsModalVisible(isOpen); 159 | Storage.save(settings); 160 | }; 161 | 162 | const resetSetting = (setting: keyof typeof settings) => { 163 | setSettings({ 164 | ...settings, 165 | [setting]: defaultSettingsRef.current[setting], 166 | }); 167 | }; 168 | 169 | useEffect(() => { 170 | setState((oldState) => { 171 | if (listening) { 172 | return State.LISTENING; 173 | } 174 | if ( 175 | (oldState === State.LISTENING && transcript) || // At this point finalTranscript may not have a value yet 176 | oldState === State.PROCESSING // Avoid setting state to IDLE when transcript is set to '' while processing 177 | ) { 178 | return State.PROCESSING; 179 | } 180 | return State.IDLE; 181 | }); 182 | }, [listening, transcript, finalTranscript]); 183 | 184 | // Scroll to bottom when user is speaking a prompt 185 | useEffect(() => { 186 | if (state === State.LISTENING) { 187 | bottomDivRef.current?.scrollIntoView({ behavior: 'smooth' }); 188 | } 189 | }, [state]); 190 | 191 | // Scroll to bottom when there is a new response 192 | useEffect(() => { 193 | bottomDivRef.current?.scrollIntoView({ behavior: 'smooth' }); 194 | }, [messages.length]); 195 | 196 | useEffect(() => { 197 | if (!defaultVoice) { 198 | return; 199 | } 200 | 201 | defaultSettingsRef.current.voiceURI = defaultVoice.voiceURI; 202 | setSettings((oldSettings) => { 203 | // If a preferred voice is already set, keep it 204 | if (oldSettings.voiceURI) { 205 | return oldSettings; 206 | } 207 | return { 208 | ...oldSettings, 209 | voiceURI: defaultVoice.voiceURI, 210 | }; 211 | }); 212 | }, [defaultVoice]); 213 | 214 | useEffect(() => { 215 | if (state !== State.PROCESSING || !finalTranscript) { 216 | return; 217 | } 218 | 219 | setMessages((oldMessages) => [ 220 | ...oldMessages, 221 | { type: 'prompt', text: finalTranscript }, 222 | ]); 223 | 224 | const host = Config.IS_LOCAL_SETUP_REQUIRED 225 | ? `${settings.host}:${settings.port}` 226 | : Config.API_HOST; 227 | const { response, abortController } = API.sendMessage(host, { 228 | text: finalTranscript, 229 | parentMessageId: conversationRef.current.currentMessageId || undefined, 230 | }); 231 | abortRef.current = abortController; 232 | 233 | response 234 | .then((res) => res.json()) 235 | .then((res: CreateChatGPTMessageResponse) => { 236 | conversationRef.current.currentMessageId = res.messageId; 237 | setMessages((oldMessages) => [ 238 | ...oldMessages, 239 | { type: 'response', text: res.answer }, 240 | ]); 241 | speak(res.answer); 242 | }) 243 | .catch((err: unknown) => { 244 | console.warn(err); 245 | let response: string; 246 | 247 | // Ignore aborted request 248 | if (abortController.signal.aborted) { 249 | return; 250 | } 251 | 252 | // Connection refused 253 | if (err instanceof TypeError && Config.IS_LOCAL_SETUP_REQUIRED) { 254 | response = 255 | 'Local server needs to be set up first. Click on the Settings button to see how.'; 256 | setIsTooltipVisible(true); 257 | } else { 258 | response = 'Failed to get the response, please try again.'; 259 | } 260 | setMessages((oldMessages) => [ 261 | ...oldMessages, 262 | { type: 'response', text: response }, 263 | ]); 264 | speak(response); 265 | }) 266 | .finally(() => { 267 | setState(State.IDLE); 268 | }); 269 | }, [state, finalTranscript, settings, speak]); 270 | 271 | if (!browserSupportsSpeechRecognition) { 272 | return ( 273 |
274 | This browser doesn't support speech recognition. Please use Chrome. 275 |
276 | ); 277 | } 278 | 279 | return ( 280 |
281 |
282 | {/* w-64 so text will break after ChatGPT */} 283 |

284 | ChatGPT With Voice 285 |
286 |

287 |
288 | 289 | 290 | 291 |
292 |
293 | 294 |
295 | {messages.map(({ type, text }, index) => { 296 | const getIsActive = () => { 297 | switch (state) { 298 | case State.IDLE: { 299 | if (type === 'prompt') { 300 | return index === messages.length - 2; 301 | } else if (type === 'response') { 302 | return index === messages.length - 1; 303 | } 304 | return false; 305 | } 306 | 307 | case State.LISTENING: 308 | return false; 309 | 310 | case State.PROCESSING: 311 | return type === 'prompt' && index === messages.length - 1; 312 | 313 | default: 314 | return false; 315 | } 316 | }; 317 | return ( 318 | 325 | ); 326 | })} 327 | {state === State.LISTENING && ( 328 | 329 | )} 330 |
331 |
332 | 333 |
334 |
335 | {!isMicrophoneAvailable && ( 336 |
337 |
338 | 339 |
340 |
341 | Please allow microphone permission for this app to work 342 | properly. 343 |
344 |
345 | )} 346 |
347 | 348 |
349 |
350 | {/** 351 | * We want a tooltip that positions itself against the Settings button. 352 | * However, we don't want the tooltip to display each time we hover on it. 353 | * So, an invisible div that is right on top of the Settings button is 354 | * used here as the tooltip's target. 355 | */} 356 | 357 | 361 | 362 |
363 | 364 | 365 | 371 | {isMobile 372 | ? 'Run a local server on Desktop to see this works.' 373 | : 'Set up local server first.'} 374 | 375 | 376 | 377 | 378 | 379 | 380 | 386 |
387 | 388 | 423 | 424 | 427 |
428 |
429 | 430 | {/* Settings modal */} 431 | 432 | 433 | 434 | 439 | 440 | Settings 441 | 442 | 443 | {Config.IS_LOCAL_SETUP_REQUIRED && ( 444 | 445 | Set up local server on Desktop in 3 easy steps. 446 | 447 | )} 448 | 449 |
450 | {Config.IS_LOCAL_SETUP_REQUIRED && ( 451 |
452 |

Step 1

453 |

454 | Clone chatgpt-server repo. 455 |

456 | 457 | git clone https://github.com/sonngdev/chatgpt-server.git 458 | 459 | 460 |

Step 2

461 |

462 | Create .env file in the project's root. You 463 | need an{' '} 464 | 465 | OpenAI account 466 | 467 | . 468 |

469 | 470 | {[ 471 | 'PORT=8000 # Or whichever port available', 472 | 'OPENAI_EMAIL=""', 473 | 'OPENAI_PASSWORD=""', 474 | ].join('\n')} 475 | 476 | 477 |

Step 3

478 |

479 | Start the server - done! Make sure you are using Node 18 or 480 | higher. 481 |

482 | 483 | {['npm install', 'npm run build', 'npm run start'].join( 484 | '\n', 485 | )} 486 | 487 |
488 | )} 489 | 490 |
491 | {Config.IS_LOCAL_SETUP_REQUIRED && isDesktop && ( 492 |
493 |

Server

494 | 495 |
496 | 497 |
498 | { 502 | setSettings({ ...settings, host: e.target.value }); 503 | }} 504 | className="border border-dark border-r-0 rounded-l-md bg-transparent p-2 flex-1" 505 | /> 506 | 513 |
514 |
515 |
516 | 517 |
518 | { 523 | setSettings({ 524 | ...settings, 525 | port: Number(e.target.value), 526 | }); 527 | }} 528 | className="border border-dark border-r-0 rounded-l-md bg-transparent p-2 flex-1" 529 | /> 530 | 537 |
538 |
539 | 540 | 541 | 542 | This app will find the server at{' '} 543 | {`${settings.host}:${settings.port}`} 544 | 545 |
546 | )} 547 | 548 |
549 |

Voice

550 | 551 |
552 | 553 |
554 | { 557 | setSettings({ 558 | ...settings, 559 | voiceURI: value, 560 | }); 561 | }} 562 | > 563 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | {Object.entries(availableVoices).map( 580 | ([group, voicesInGroup], index) => ( 581 | 582 | {index > 0 && ( 583 | 584 | )} 585 | 586 | 587 | 588 | {group} 589 | 590 | {voicesInGroup.map((voice) => ( 591 | 596 | 597 | {voice.name} 598 | 599 | 600 | 601 | 602 | 603 | ))} 604 | 605 | 606 | ), 607 | )} 608 | 609 | 610 | 611 | 612 | 613 | 614 | 615 | 622 |
623 |
624 | 625 |
626 | 627 |
628 | { 633 | setSettings({ ...settings, voiceSpeed: newSpeed }); 634 | }} 635 | max={2} 636 | min={0.5} 637 | step={0.1} 638 | aria-label="Voice speed" 639 | > 640 | 641 | 642 | 643 | 644 | 645 |
646 | {`${settings.voiceSpeed.toFixed(2)}x`} 647 |
648 | 654 |
655 |
656 | 657 | 665 |
666 |
667 |
668 | 669 | 670 | 677 | 678 |
679 |
680 |
681 |
682 | ); 683 | } 684 | 685 | export default App; 686 | -------------------------------------------------------------------------------- /src/design_system/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ButtonHTMLAttributes, DetailedHTMLProps, forwardRef } from 'react'; 2 | 3 | interface ButtonProps 4 | extends DetailedHTMLProps< 5 | ButtonHTMLAttributes, 6 | HTMLButtonElement 7 | > { 8 | size?: 'large' | 'normal' | 'small'; 9 | variant?: 'outline' | 'solid'; 10 | iconOnly?: boolean; 11 | } 12 | 13 | const Button = forwardRef((props, ref) => { 14 | const { 15 | size = 'normal', 16 | variant = 'outline', 17 | iconOnly = true, 18 | className = '', 19 | ...rest 20 | } = props; 21 | 22 | const getClassNameFromSize = () => { 23 | if (size === 'normal') { 24 | if (iconOnly) { 25 | return 'w-11 h-11'; 26 | } 27 | return 'px-3 py-2 rounded-md'; 28 | } 29 | if (size === 'small') { 30 | if (iconOnly) { 31 | return 'w-6 h-6'; 32 | } 33 | return 'px-2 py-1 text-xs rounded-sm'; 34 | } 35 | return ''; 36 | }; 37 | 38 | const getClassNameFromVariant = () => { 39 | if (variant === 'outline') { 40 | return 'border border-dark bg-transparent hover:opacity-60 focus:opacity-60'; 41 | } 42 | return ''; 43 | }; 44 | 45 | const getClassNameFromIconOnly = () => { 46 | if (iconOnly) { 47 | return 'rounded-full'; 48 | } 49 | return ''; 50 | }; 51 | 52 | const cn = [ 53 | getClassNameFromSize(), 54 | getClassNameFromVariant(), 55 | getClassNameFromIconOnly(), 56 | 'flex justify-center items-center transition-opacity', 57 | className, 58 | ].join(' '); 59 | 60 | return