├── src
├── vite-env.d.ts
├── http
│ ├── types
│ │ ├── create-room-response.ts
│ │ ├── create-question-request.ts
│ │ ├── create-room-request.ts
│ │ ├── create-question-response.ts
│ │ ├── get-rooms-response.ts
│ │ └── get-room-questions-response.ts
│ ├── use-rooms.ts
│ ├── use-room-questions.ts
│ ├── use-create-room.ts
│ └── use-create-question.ts
├── lib
│ ├── utils.ts
│ └── dayjs.ts
├── main.tsx
├── pages
│ ├── create-room.tsx
│ ├── room.tsx
│ └── record-room-audio.tsx
├── components
│ ├── ui
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── form.tsx
│ ├── question-list.tsx
│ ├── room-list.tsx
│ ├── question-item.tsx
│ ├── question-form.tsx
│ └── create-room-form.tsx
├── app.tsx
└── index.css
├── biome.jsonc
├── .gitignore
├── index.html
├── tsconfig.json
├── vite.config.ts
├── components.json
├── tsconfig.node.json
├── tsconfig.app.json
├── package.json
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/http/types/create-room-response.ts:
--------------------------------------------------------------------------------
1 | export type CreateRoomResponse = {
2 | roomId: string
3 | }
4 |
--------------------------------------------------------------------------------
/src/http/types/create-question-request.ts:
--------------------------------------------------------------------------------
1 | export type CreateQuestionRequest = {
2 | question: string
3 | }
4 |
--------------------------------------------------------------------------------
/src/http/types/create-room-request.ts:
--------------------------------------------------------------------------------
1 | export type CreateRoomRequest = {
2 | name: string
3 | description: string
4 | }
5 |
--------------------------------------------------------------------------------
/src/http/types/create-question-response.ts:
--------------------------------------------------------------------------------
1 | export type CreateQuestionResponse = {
2 | questionId: string
3 | answer: string | null
4 | }
5 |
--------------------------------------------------------------------------------
/src/http/types/get-rooms-response.ts:
--------------------------------------------------------------------------------
1 | export type GetRoomsResponse = Array<{
2 | id: string
3 | name: string
4 | questionsCount: number
5 | createdAt: string
6 | }>
7 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/dayjs.ts:
--------------------------------------------------------------------------------
1 | import lib from 'dayjs'
2 | import 'dayjs/locale/pt-BR'
3 | import relativeTime from 'dayjs/plugin/relativeTime'
4 |
5 | lib.locale('pt-BR')
6 | lib.extend(relativeTime)
7 |
8 | export const dayjs = lib
9 |
--------------------------------------------------------------------------------
/src/http/types/get-room-questions-response.ts:
--------------------------------------------------------------------------------
1 | export type GetRoomQuestionsResponse = Array<{
2 | id: string
3 | question: string
4 | answer: string | null
5 | createdAt: string
6 | isGeneratingAnswer?: boolean
7 | }>
8 |
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
3 | "extends": [
4 | "ultracite"
5 | ],
6 | "javascript": {
7 | "formatter": {
8 | "semicolons": "asNeeded"
9 | }
10 | }
11 | }
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import './index.css'
2 |
3 | import { StrictMode } from 'react'
4 | import { createRoot } from 'react-dom/client'
5 | import { App } from './app'
6 |
7 | // biome-ignore lint/style/noNonNullAssertion: mandatory by React
8 | createRoot(document.getElementById('root')!).render(
9 |
10 |
11 |
12 | )
13 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Let me Ask
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ],
11 | "compilerOptions": {
12 | "strictNullChecks": true,
13 | "baseUrl": ".",
14 | "paths": {
15 | "@/*": [
16 | "./src/*"
17 | ]
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path'
2 | import tailwindcss from '@tailwindcss/vite'
3 | import react from '@vitejs/plugin-react'
4 | import { defineConfig } from 'vite'
5 |
6 | export default defineConfig({
7 | plugins: [react(), tailwindcss()],
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, './src'),
11 | },
12 | },
13 | })
14 |
--------------------------------------------------------------------------------
/src/http/use-rooms.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import type { GetRoomsResponse } from './types/get-rooms-response'
3 |
4 | export function useRooms() {
5 | return useQuery({
6 | queryKey: ['get-rooms'],
7 | queryFn: async () => {
8 | const response = await fetch('http://localhost:3333/rooms')
9 | const result: GetRoomsResponse = await response.json()
10 |
11 | return result
12 | },
13 | })
14 | }
15 |
--------------------------------------------------------------------------------
/src/pages/create-room.tsx:
--------------------------------------------------------------------------------
1 | import { CreateRoomForm } from '@/components/create-room-form'
2 | import { RoomList } from '@/components/room-list'
3 |
4 | export function CreateRoom() {
5 | return (
6 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
--------------------------------------------------------------------------------
/src/http/use-room-questions.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import type { GetRoomQuestionsResponse } from './types/get-room-questions-response'
3 |
4 | export function useRoomQuestions(roomId: string) {
5 | return useQuery({
6 | queryKey: ['get-questions', roomId],
7 | queryFn: async () => {
8 | const response = await fetch(
9 | `http://localhost:3333/rooms/${roomId}/questions`
10 | )
11 | const result: GetRoomQuestionsResponse = await response.json()
12 |
13 | return result
14 | },
15 | })
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/components/question-list.tsx:
--------------------------------------------------------------------------------
1 | import { useRoomQuestions } from '@/http/use-room-questions'
2 | import { QuestionItem } from './question-item'
3 |
4 | interface QuestionListProps {
5 | roomId: string
6 | }
7 |
8 | export function QuestionList(props: QuestionListProps) {
9 | const { data } = useRoomQuestions(props.roomId)
10 |
11 | return (
12 |
13 |
14 |
15 | Perguntas & Respostas
16 |
17 |
18 |
19 | {data?.map((question) => {
20 | return
21 | })}
22 |
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "verbatimModuleSyntax": true,
13 | "moduleDetection": "force",
14 | "noEmit": true,
15 |
16 | /* Linting */
17 | "strict": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "erasableSyntaxOnly": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUncheckedSideEffectImports": true
23 | },
24 | "include": ["vite.config.ts"]
25 | }
26 |
--------------------------------------------------------------------------------
/src/app.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
2 | import { BrowserRouter, Route, Routes } from 'react-router-dom'
3 | import { CreateRoom } from './pages/create-room'
4 | import { RecordRoomAudio } from './pages/record-room-audio'
5 | import { Room } from './pages/room'
6 |
7 | const queryClient = new QueryClient()
8 |
9 | export function App() {
10 | return (
11 |
12 |
13 |
14 | } index />
15 | } path="/room/:roomId" />
16 | } path="/room/:roomId/audio" />
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/src/http/use-create-room.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 | import type { CreateRoomRequest } from './types/create-room-request'
3 | import type { CreateRoomResponse } from './types/create-room-response'
4 |
5 | export function useCreateRoom() {
6 | const queryClient = useQueryClient()
7 |
8 | return useMutation({
9 | mutationFn: async (data: CreateRoomRequest) => {
10 | const response = await fetch('http://localhost:3333/rooms', {
11 | method: 'POST',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | },
15 | body: JSON.stringify(data),
16 | })
17 |
18 | const result: CreateRoomResponse = await response.json()
19 |
20 | return result
21 | },
22 |
23 | onSuccess: () => {
24 | queryClient.invalidateQueries({ queryKey: ['get-rooms'] })
25 | },
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": [
7 | "ES2022",
8 | "DOM",
9 | "DOM.Iterable"
10 | ],
11 | "module": "ESNext",
12 | "skipLibCheck": true,
13 | "moduleResolution": "bundler",
14 | "allowImportingTsExtensions": true,
15 | "verbatimModuleSyntax": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "erasableSyntaxOnly": true,
23 | "noFallthroughCasesInSwitch": true,
24 | "noUncheckedSideEffectImports": true,
25 | "baseUrl": ".",
26 | "paths": {
27 | "@/*": [
28 | "./src/*"
29 | ]
30 | }
31 | },
32 | "include": [
33 | "src"
34 | ]
35 | }
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
6 | return (
7 |
18 | )
19 | }
20 |
21 | export { Input }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "web",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "5.1.1",
13 | "@radix-ui/react-label": "2.1.7",
14 | "@radix-ui/react-slot": "1.2.3",
15 | "@tailwindcss/vite": "4.1.11",
16 | "@tanstack/react-query": "5.81.5",
17 | "class-variance-authority": "0.7.1",
18 | "clsx": "2.1.1",
19 | "dayjs": "1.11.13",
20 | "lucide-react": "0.525.0",
21 | "react": "^19.1.0",
22 | "react-dom": "^19.1.0",
23 | "react-hook-form": "7.59.0",
24 | "react-router-dom": "7.6.3",
25 | "tailwind-merge": "3.3.1",
26 | "tailwindcss": "4.1.11",
27 | "zod": "3.25.67"
28 | },
29 | "devDependencies": {
30 | "@biomejs/biome": "2.0.5",
31 | "@types/dom-speech-recognition": "0.0.6",
32 | "@types/node": "24.0.8",
33 | "@types/react": "^19.1.8",
34 | "@types/react-dom": "^19.1.6",
35 | "@vitejs/plugin-react": "^4.5.2",
36 | "tw-animate-css": "1.3.4",
37 | "typescript": "~5.8.3",
38 | "ultracite": "5.0.27",
39 | "vite": "^7.0.0"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/pages/room.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowLeft, Radio } from 'lucide-react'
2 | import { Link, Navigate, useParams } from 'react-router-dom'
3 | import { QuestionForm } from '@/components/question-form'
4 | import { QuestionList } from '@/components/question-list'
5 | import { Button } from '@/components/ui/button'
6 |
7 | type RoomParams = {
8 | roomId: string
9 | }
10 |
11 | export function Room() {
12 | const params = useParams()
13 |
14 | if (!params.roomId) {
15 | return
16 | }
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 |
28 |
29 |
30 |
34 |
35 |
36 |
37 | Sala de Perguntas
38 |
39 |
40 | Faça perguntas e receba respostas com IA
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import type * as React from 'react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const badgeVariants = cva(
8 | 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
14 | secondary:
15 | 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
16 | destructive:
17 | 'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
18 | outline:
19 | 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
20 | },
21 | },
22 | defaultVariants: {
23 | variant: 'default',
24 | },
25 | }
26 | )
27 |
28 | function Badge({
29 | className,
30 | variant,
31 | asChild = false,
32 | ...props
33 | }: React.ComponentProps<'span'> &
34 | VariantProps & { asChild?: boolean }) {
35 | const Comp = asChild ? Slot : 'span'
36 |
37 | return (
38 |
43 | )
44 | }
45 |
46 | export { Badge, badgeVariants }
47 |
--------------------------------------------------------------------------------
/src/components/room-list.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight } from 'lucide-react'
2 | import { Link } from 'react-router-dom'
3 | import { useRooms } from '@/http/use-rooms'
4 | import { dayjs } from '@/lib/dayjs'
5 | import { Badge } from './ui/badge'
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from './ui/card'
13 |
14 | export function RoomList() {
15 | const { data, isLoading } = useRooms()
16 |
17 | return (
18 |
19 |
20 | Salas recentes
21 |
22 | Acesso rápido para as salas criadas recentemente
23 |
24 |
25 |
26 | {isLoading && (
27 | Carregando salas...
28 | )}
29 |
30 | {data?.map((room) => {
31 | return (
32 |
37 |
38 |
{room.name}
39 |
40 |
41 |
42 | {dayjs(room.createdAt).toNow()}
43 |
44 |
45 | {room.questionsCount} pergunta(s)
46 |
47 |
48 |
49 |
50 |
51 | Entrar
52 |
53 |
54 |
55 | )
56 | })}
57 |
58 |
59 | )
60 | }
61 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NLW Agents
2 |
3 | Projeto desenvolvido durante um evento da Rocketseat para demonstrar o uso de agentes inteligentes na web.
4 |
5 | ## 🚀 Tecnologias
6 |
7 | - **React 19.1** - Biblioteca para interfaces de usuário
8 | - **TypeScript 5.8** - Superset JavaScript com tipagem estática
9 | - **Vite 7.0** - Build tool e servidor de desenvolvimento
10 | - **TailwindCSS 4.1** - Framework CSS utility-first
11 | - **React Router Dom 7.6** - Biblioteca de roteamento
12 | - **TanStack React Query 5.8** - Gerenciamento de estado servidor e cache
13 | - **Radix UI** - Componentes primitivos acessíveis
14 | - **Shadcn/ui** - Sistema de componentes
15 | - **Lucide React** - Biblioteca de ícones
16 |
17 | ## 📂 Padrões de Projeto
18 |
19 | - **Component-based Architecture** - Arquitetura baseada em componentes React
20 | - **File-based Routing** - Roteamento baseado em arquivos com React Router
21 | - **Server State Management** - Gerenciamento de estado servidor com React Query
22 | - **Variant-based Components** - Componentes com variantes usando CVA
23 | - **Composition Pattern** - Padrão de composição com Radix Slot
24 | - **Path Aliasing** - Alias de caminhos (`@/` aponta para `src/`)
25 |
26 | ## ⚙️ Configuração do Projeto
27 |
28 | ### Pré-requisitos
29 |
30 | - Node.js (versão 18 ou superior)
31 | - npm ou yarn
32 |
33 | ### Instalação
34 |
35 | 1. Clone o repositório
36 | 2. Instale as dependências:
37 | ```bash
38 | npm install
39 | ```
40 |
41 | 3. Execute o servidor de desenvolvimento:
42 | ```bash
43 | npm run dev
44 | ```
45 |
46 | 4. Acesse a aplicação em `http://localhost:5173`
47 |
48 | ### Scripts Disponíveis
49 |
50 | - `npm run dev` - Inicia o servidor de desenvolvimento
51 | - `npm run build` - Gera build de produção
52 | - `npm run preview` - Preview do build de produção
53 |
54 | ### Backend
55 |
56 | O projeto consome uma API que deve estar rodando na porta 3333. Certifique-se de que o backend esteja configurado e executando antes de iniciar o frontend.
57 |
58 | ## 🛠️ Estrutura do Projeto
59 |
60 | ```
61 | src/
62 | ├── components/ui/ # Componentes de interface
63 | ├── pages/ # Páginas da aplicação
64 | ├── lib/ # Utilitários e configurações
65 | └── app.tsx # Componente raiz
66 | ```
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import type * as React from 'react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | "inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
14 | destructive:
15 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
16 | outline:
17 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
20 | ghost:
21 | 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
22 | link: 'text-primary underline-offset-4 hover:underline',
23 | },
24 | size: {
25 | default: 'h-9 px-4 py-2 has-[>svg]:px-3',
26 | sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
27 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
28 | icon: 'size-9',
29 | },
30 | },
31 | defaultVariants: {
32 | variant: 'default',
33 | size: 'default',
34 | },
35 | }
36 | )
37 |
38 | function Button({
39 | className,
40 | variant,
41 | size,
42 | asChild = false,
43 | ...props
44 | }: React.ComponentProps<'button'> &
45 | VariantProps & {
46 | asChild?: boolean
47 | }) {
48 | const Comp = asChild ? Slot : 'button'
49 |
50 | return (
51 |
56 | )
57 | }
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | function Card({ className, ...props }: React.ComponentProps<'div'>) {
6 | return (
7 |
15 | )
16 | }
17 |
18 | function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
19 | return (
20 |
28 | )
29 | }
30 |
31 | function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
32 | return (
33 |
38 | )
39 | }
40 |
41 | function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
42 | return (
43 |
48 | )
49 | }
50 |
51 | function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
52 | return (
53 |
61 | )
62 | }
63 |
64 | function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
65 | return (
66 |
71 | )
72 | }
73 |
74 | function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
75 | return (
76 |
81 | )
82 | }
83 |
84 | export {
85 | Card,
86 | CardHeader,
87 | CardFooter,
88 | CardTitle,
89 | CardAction,
90 | CardDescription,
91 | CardContent,
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/question-item.tsx:
--------------------------------------------------------------------------------
1 | import { Bot, Loader2, MessageSquare } from 'lucide-react'
2 | import { Card, CardContent } from '@/components/ui/card'
3 | import { dayjs } from '@/lib/dayjs'
4 |
5 | interface Question {
6 | id: string
7 | question: string
8 | answer?: string | null
9 | createdAt: string
10 | isGeneratingAnswer?: boolean
11 | }
12 |
13 | interface QuestionItemProps {
14 | question: Question
15 | }
16 |
17 | export function QuestionItem({ question }: QuestionItemProps) {
18 | return (
19 |
20 |
21 |
22 | {/* Question */}
23 |
24 |
29 |
30 |
Pergunta
31 |
32 | {question.question}
33 |
34 |
35 |
36 |
37 | {(!!question.answer || question.isGeneratingAnswer) && (
38 |
39 |
44 |
45 |
46 | Resposta da IA
47 |
48 |
49 | {question.isGeneratingAnswer ? (
50 |
51 |
52 |
53 | Gerando resposta...
54 |
55 |
56 | ) : (
57 |
58 | {question.answer}
59 |
60 | )}
61 |
62 |
63 |
64 | )}
65 |
66 |
67 |
68 | {dayjs(question.createdAt).toNow()}
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/http/use-create-question.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query'
2 | import type { CreateQuestionRequest } from './types/create-question-request'
3 | import type { CreateQuestionResponse } from './types/create-question-response'
4 | import type { GetRoomQuestionsResponse } from './types/get-room-questions-response'
5 |
6 | export function useCreateQuestion(roomId: string) {
7 | const queryClient = useQueryClient()
8 |
9 | return useMutation({
10 | mutationFn: async (data: CreateQuestionRequest) => {
11 | const response = await fetch(
12 | `http://localhost:3333/rooms/${roomId}/questions`,
13 | {
14 | method: 'POST',
15 | headers: {
16 | 'Content-Type': 'application/json',
17 | },
18 | body: JSON.stringify(data),
19 | }
20 | )
21 |
22 | const result: CreateQuestionResponse = await response.json()
23 |
24 | return result
25 | },
26 |
27 | // Executa no momento que for feita a chamada p/ API
28 | onMutate({ question }) {
29 | const questions = queryClient.getQueryData([
30 | 'get-questions',
31 | roomId,
32 | ])
33 |
34 | const questionsArray = questions ?? []
35 |
36 | const newQuestion = {
37 | id: crypto.randomUUID(),
38 | question,
39 | answer: null,
40 | createdAt: new Date().toISOString(),
41 | isGeneratingAnswer: true,
42 | }
43 |
44 | queryClient.setQueryData(
45 | ['get-questions', roomId],
46 | [newQuestion, ...questionsArray]
47 | )
48 |
49 | return { newQuestion, questions }
50 | },
51 |
52 | onSuccess(data, _variables, context) {
53 | queryClient.setQueryData(
54 | ['get-questions', roomId],
55 | (questions) => {
56 | if (!questions) {
57 | return questions
58 | }
59 |
60 | if (!context.newQuestion) {
61 | return questions
62 | }
63 |
64 | return questions.map((question) => {
65 | if (question.id === context.newQuestion.id) {
66 | return {
67 | ...context.newQuestion,
68 | id: data.questionId,
69 | answer: data.answer,
70 | isGeneratingAnswer: false,
71 | }
72 | }
73 |
74 | return question
75 | })
76 | }
77 | )
78 | },
79 |
80 | onError(_error, _variables, context) {
81 | if (context?.questions) {
82 | queryClient.setQueryData(
83 | ['get-questions', roomId],
84 | context.questions
85 | )
86 | }
87 | },
88 |
89 | // onSuccess: () => {
90 | // queryClient.invalidateQueries({ queryKey: ['get-questions', roomId] })
91 | // },
92 | })
93 | }
94 |
--------------------------------------------------------------------------------
/src/components/question-form.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import { useForm } from 'react-hook-form'
3 | import { z } from 'zod'
4 | import { Button } from '@/components/ui/button'
5 | import {
6 | Card,
7 | CardContent,
8 | CardDescription,
9 | CardHeader,
10 | CardTitle,
11 | } from '@/components/ui/card'
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from '@/components/ui/form'
20 | import { Textarea } from '@/components/ui/textarea'
21 | import { useCreateQuestion } from '@/http/use-create-question'
22 |
23 | // Esquema de validação no mesmo arquivo conforme solicitado
24 | const createQuestionSchema = z.object({
25 | question: z
26 | .string()
27 | .min(1, 'Pergunta é obrigatória')
28 | .min(10, 'Pergunta deve ter pelo menos 10 caracteres')
29 | .max(500, 'Pergunta deve ter menos de 500 caracteres'),
30 | })
31 |
32 | type CreateQuestionFormData = z.infer
33 |
34 | interface QuestionFormProps {
35 | roomId: string
36 | }
37 |
38 | export function QuestionForm({ roomId }: QuestionFormProps) {
39 | const { mutateAsync: createQuestion } = useCreateQuestion(roomId)
40 |
41 | const form = useForm({
42 | resolver: zodResolver(createQuestionSchema),
43 | defaultValues: {
44 | question: '',
45 | },
46 | })
47 |
48 | async function handleCreateQuestion(data: CreateQuestionFormData) {
49 | await createQuestion(data)
50 | }
51 |
52 | const { isSubmitting } = form.formState
53 |
54 | return (
55 |
56 |
57 | Fazer uma Pergunta
58 |
59 | Digite sua pergunta abaixo para receber uma resposta gerada por I.A.
60 |
61 |
62 |
63 |
91 |
92 |
93 |
94 | )
95 | }
96 |
--------------------------------------------------------------------------------
/src/pages/record-room-audio.tsx:
--------------------------------------------------------------------------------
1 | /** biome-ignore-all lint/suspicious/noConsole: */
2 | import { useRef, useState } from 'react'
3 | import { Navigate, useParams } from 'react-router-dom'
4 | import { Button } from '@/components/ui/button'
5 |
6 | const isRecordingSupported =
7 | !!navigator.mediaDevices &&
8 | typeof navigator.mediaDevices.getUserMedia === 'function' &&
9 | typeof window.MediaRecorder === 'function'
10 |
11 | type RoomParams = {
12 | roomId: string
13 | }
14 |
15 | export function RecordRoomAudio() {
16 | const params = useParams()
17 | const [isRecording, setIsRecording] = useState(false)
18 | const recorder = useRef(null)
19 | const intervalRef = useRef(null)
20 |
21 | function stopRecording() {
22 | setIsRecording(false)
23 |
24 | if (recorder.current && recorder.current.state !== 'inactive') {
25 | recorder.current.stop()
26 | }
27 |
28 | if (intervalRef.current) {
29 | clearInterval(intervalRef.current)
30 | }
31 | }
32 |
33 | async function uploadAudio(audio: Blob) {
34 | const formData = new FormData()
35 |
36 | formData.append('file', audio, 'audio.webm')
37 |
38 | const response = await fetch(
39 | `http://localhost:3333/rooms/${params.roomId}/audio`,
40 | {
41 | method: 'POST',
42 | body: formData,
43 | }
44 | )
45 |
46 | const result = await response.json()
47 |
48 | console.log(result)
49 | }
50 |
51 | function createRecorder(audio: MediaStream) {
52 | recorder.current = new MediaRecorder(audio, {
53 | mimeType: 'audio/webm',
54 | audioBitsPerSecond: 64_000,
55 | })
56 |
57 | recorder.current.ondataavailable = (event) => {
58 | if (event.data.size > 0) {
59 | uploadAudio(event.data)
60 | }
61 | }
62 |
63 | recorder.current.onstart = () => {
64 | console.log('Gravação iniciada!')
65 | }
66 |
67 | recorder.current.onstop = () => {
68 | console.log('Gravação encerrada/pausada')
69 | }
70 |
71 | recorder.current.start()
72 | }
73 |
74 | async function startRecording() {
75 | if (!isRecordingSupported) {
76 | alert('O seu navegador não suporta gravação')
77 | return
78 | }
79 |
80 | setIsRecording(true)
81 |
82 | const audio = await navigator.mediaDevices.getUserMedia({
83 | audio: {
84 | echoCancellation: true,
85 | noiseSuppression: true,
86 | sampleRate: 44_100,
87 | },
88 | })
89 |
90 | createRecorder(audio)
91 |
92 | intervalRef.current = setInterval(() => {
93 | recorder.current?.stop()
94 |
95 | createRecorder(audio)
96 | }, 5000)
97 | }
98 |
99 | if (!params.roomId) {
100 | return
101 | }
102 |
103 | return (
104 |
105 | {isRecording ? (
106 |
107 | ) : (
108 |
109 | )}
110 | {isRecording ?
Gravando...
:
Pausado
}
111 |
112 | )
113 | }
114 |
--------------------------------------------------------------------------------
/src/components/create-room-form.tsx:
--------------------------------------------------------------------------------
1 | import { zodResolver } from '@hookform/resolvers/zod'
2 | import { useForm } from 'react-hook-form'
3 | import { z } from 'zod/v4'
4 | import { useCreateRoom } from '@/http/use-create-room'
5 | import { Button } from './ui/button'
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from './ui/card'
13 | import {
14 | Form,
15 | FormControl,
16 | FormField,
17 | FormItem,
18 | FormLabel,
19 | FormMessage,
20 | } from './ui/form'
21 | import { Input } from './ui/input'
22 | import { Textarea } from './ui/textarea'
23 |
24 | const createRoomSchema = z.object({
25 | name: z.string().min(3, { message: 'Inclua no mínimo 3 caracteres' }),
26 | description: z.string(),
27 | })
28 |
29 | type CreateRoomFormData = z.infer
30 |
31 | export function CreateRoomForm() {
32 | const { mutateAsync: createRoom } = useCreateRoom()
33 |
34 | const createRoomForm = useForm({
35 | resolver: zodResolver(createRoomSchema),
36 | defaultValues: {
37 | name: '',
38 | description: '',
39 | },
40 | })
41 |
42 | async function handleCreateRoom({ name, description }: CreateRoomFormData) {
43 | await createRoom({ name, description })
44 |
45 | createRoomForm.reset()
46 | }
47 |
48 | return (
49 |
50 |
51 | Criar sala
52 |
53 | Crie uam nova sala para começar a fazer perguntas e receber respostas
54 | da I.A.
55 |
56 |
57 |
58 |
102 |
103 |
104 |
105 | )
106 | }
107 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import type * as LabelPrimitive from '@radix-ui/react-label'
2 | import { Slot } from '@radix-ui/react-slot'
3 | import { createContext, useContext, useId } from 'react'
4 | import {
5 | Controller,
6 | type ControllerProps,
7 | type FieldPath,
8 | type FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | useFormState,
12 | } from 'react-hook-form'
13 | import { Label } from '@/components/ui/label'
14 | import { cn } from '@/lib/utils'
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath,
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath,
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = useContext(FormFieldContext)
44 | const itemContext = useContext(FormItemContext)
45 | const { getFieldState } = useFormContext()
46 | const formState = useFormState({ name: fieldContext.name })
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error('useFormField should be used within ')
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
74 | const id = useId()
75 |
76 | return (
77 |
78 |
83 |
84 | )
85 | }
86 |
87 | function FormLabel({
88 | className,
89 | ...props
90 | }: React.ComponentProps) {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
101 | )
102 | }
103 |
104 | function FormControl({ ...props }: React.ComponentProps) {
105 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
106 |
107 | return (
108 |
117 | )
118 | }
119 |
120 | function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
121 | const { formDescriptionId } = useFormField()
122 |
123 | return (
124 |
130 | )
131 | }
132 |
133 | function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
134 | const { error, formMessageId } = useFormField()
135 | const body = error ? String(error?.message ?? '') : props.children
136 |
137 | if (!body) {
138 | return null
139 | }
140 |
141 | return (
142 |
148 | {body}
149 |
150 | )
151 | }
152 |
153 | export {
154 | useFormField,
155 | Form,
156 | FormItem,
157 | FormLabel,
158 | FormControl,
159 | FormDescription,
160 | FormMessage,
161 | FormField,
162 | }
163 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --radius-sm: calc(var(--radius) - 4px);
8 | --radius-md: calc(var(--radius) - 2px);
9 | --radius-lg: var(--radius);
10 | --radius-xl: calc(var(--radius) + 4px);
11 | --color-background: var(--background);
12 | --color-foreground: var(--foreground);
13 | --color-card: var(--card);
14 | --color-card-foreground: var(--card-foreground);
15 | --color-popover: var(--popover);
16 | --color-popover-foreground: var(--popover-foreground);
17 | --color-primary: var(--primary);
18 | --color-primary-foreground: var(--primary-foreground);
19 | --color-secondary: var(--secondary);
20 | --color-secondary-foreground: var(--secondary-foreground);
21 | --color-muted: var(--muted);
22 | --color-muted-foreground: var(--muted-foreground);
23 | --color-accent: var(--accent);
24 | --color-accent-foreground: var(--accent-foreground);
25 | --color-destructive: var(--destructive);
26 | --color-border: var(--border);
27 | --color-input: var(--input);
28 | --color-ring: var(--ring);
29 | --color-chart-1: var(--chart-1);
30 | --color-chart-2: var(--chart-2);
31 | --color-chart-3: var(--chart-3);
32 | --color-chart-4: var(--chart-4);
33 | --color-chart-5: var(--chart-5);
34 | --color-sidebar: var(--sidebar);
35 | --color-sidebar-foreground: var(--sidebar-foreground);
36 | --color-sidebar-primary: var(--sidebar-primary);
37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
38 | --color-sidebar-accent: var(--sidebar-accent);
39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
40 | --color-sidebar-border: var(--sidebar-border);
41 | --color-sidebar-ring: var(--sidebar-ring);
42 | }
43 |
44 | :root {
45 | --radius: 0.625rem;
46 | --background: oklch(1 0 0);
47 | --foreground: oklch(0.141 0.005 285.823);
48 | --card: oklch(1 0 0);
49 | --card-foreground: oklch(0.141 0.005 285.823);
50 | --popover: oklch(1 0 0);
51 | --popover-foreground: oklch(0.141 0.005 285.823);
52 | --primary: oklch(0.21 0.006 285.885);
53 | --primary-foreground: oklch(0.985 0 0);
54 | --secondary: oklch(0.967 0.001 286.375);
55 | --secondary-foreground: oklch(0.21 0.006 285.885);
56 | --muted: oklch(0.967 0.001 286.375);
57 | --muted-foreground: oklch(0.552 0.016 285.938);
58 | --accent: oklch(0.967 0.001 286.375);
59 | --accent-foreground: oklch(0.21 0.006 285.885);
60 | --destructive: oklch(0.577 0.245 27.325);
61 | --border: oklch(0.92 0.004 286.32);
62 | --input: oklch(0.92 0.004 286.32);
63 | --ring: oklch(0.705 0.015 286.067);
64 | --chart-1: oklch(0.646 0.222 41.116);
65 | --chart-2: oklch(0.6 0.118 184.704);
66 | --chart-3: oklch(0.398 0.07 227.392);
67 | --chart-4: oklch(0.828 0.189 84.429);
68 | --chart-5: oklch(0.769 0.188 70.08);
69 | --sidebar: oklch(0.985 0 0);
70 | --sidebar-foreground: oklch(0.141 0.005 285.823);
71 | --sidebar-primary: oklch(0.21 0.006 285.885);
72 | --sidebar-primary-foreground: oklch(0.985 0 0);
73 | --sidebar-accent: oklch(0.967 0.001 286.375);
74 | --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
75 | --sidebar-border: oklch(0.92 0.004 286.32);
76 | --sidebar-ring: oklch(0.705 0.015 286.067);
77 | }
78 |
79 | .dark {
80 | --background: oklch(0.141 0.005 285.823);
81 | --foreground: oklch(0.985 0 0);
82 | --card: oklch(0.21 0.006 285.885);
83 | --card-foreground: oklch(0.985 0 0);
84 | --popover: oklch(0.21 0.006 285.885);
85 | --popover-foreground: oklch(0.985 0 0);
86 | --primary: oklch(0.92 0.004 286.32);
87 | --primary-foreground: oklch(0.21 0.006 285.885);
88 | --secondary: oklch(0.274 0.006 286.033);
89 | --secondary-foreground: oklch(0.985 0 0);
90 | --muted: oklch(0.274 0.006 286.033);
91 | --muted-foreground: oklch(0.705 0.015 286.067);
92 | --accent: oklch(0.274 0.006 286.033);
93 | --accent-foreground: oklch(0.985 0 0);
94 | --destructive: oklch(0.704 0.191 22.216);
95 | --border: oklch(1 0 0 / 10%);
96 | --input: oklch(1 0 0 / 15%);
97 | --ring: oklch(0.552 0.016 285.938);
98 | --chart-1: oklch(0.488 0.243 264.376);
99 | --chart-2: oklch(0.696 0.17 162.48);
100 | --chart-3: oklch(0.769 0.188 70.08);
101 | --chart-4: oklch(0.627 0.265 303.9);
102 | --chart-5: oklch(0.645 0.246 16.439);
103 | --sidebar: oklch(0.21 0.006 285.885);
104 | --sidebar-foreground: oklch(0.985 0 0);
105 | --sidebar-primary: oklch(0.488 0.243 264.376);
106 | --sidebar-primary-foreground: oklch(0.985 0 0);
107 | --sidebar-accent: oklch(0.274 0.006 286.033);
108 | --sidebar-accent-foreground: oklch(0.985 0 0);
109 | --sidebar-border: oklch(1 0 0 / 10%);
110 | --sidebar-ring: oklch(0.552 0.016 285.938);
111 | }
112 |
113 | @layer base {
114 | * {
115 | @apply border-border outline-ring/50;
116 | }
117 | body {
118 | @apply bg-background text-foreground;
119 | }
120 | }
--------------------------------------------------------------------------------