├── src ├── vite-env.d.ts ├── index.css ├── lib │ └── react-query.ts ├── main.tsx ├── http │ ├── create-message-reaction.ts │ ├── remove-message-reaction.ts │ ├── create-room.ts │ ├── create-message.ts │ └── get-room-messages.ts ├── app.tsx ├── components │ ├── messages.tsx │ ├── create-message-form.tsx │ └── message.tsx ├── pages │ ├── room.tsx │ └── create-room.tsx ├── assets │ └── ama-logo.svg └── hooks │ └── use-messages-web-sockets.ts ├── public └── icon.png ├── postcss.config.js ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── .gitignore ├── tsconfig.node.json ├── index.html ├── .eslintrc.cjs ├── tsconfig.app.json └── package.json /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rocketseat-education/semana-tech-01-go-react-web/HEAD/public/icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/react-query.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const queryClient = new QueryClient() -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | './src/**/*.tsx', 5 | './index.html' 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { App } from './app' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/http/create-message-reaction.ts: -------------------------------------------------------------------------------- 1 | interface CreateMessageReactionRequest { 2 | roomId: string 3 | messageId: string 4 | } 5 | 6 | export async function createMessageReaction({ messageId, roomId }: CreateMessageReactionRequest) { 7 | await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages/${messageId}/react`, { 8 | method: 'PATCH', 9 | }) 10 | } -------------------------------------------------------------------------------- /src/http/remove-message-reaction.ts: -------------------------------------------------------------------------------- 1 | interface RemoveMessageReactionRequest { 2 | roomId: string 3 | messageId: string 4 | } 5 | 6 | export async function removeMessageReaction({ messageId, roomId }: RemoveMessageReactionRequest) { 7 | await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages/${messageId}/react`, { 8 | method: 'DELETE', 9 | }) 10 | } -------------------------------------------------------------------------------- /.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 | 26 | .env -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 5 | "skipLibCheck": true, 6 | "module": "ESNext", 7 | "moduleResolution": "bundler", 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "noEmit": true 11 | }, 12 | "include": ["vite.config.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /src/http/create-room.ts: -------------------------------------------------------------------------------- 1 | interface CreateRoomRequest { 2 | theme: string 3 | } 4 | 5 | export async function createRoom({ theme }: CreateRoomRequest) { 6 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms`, { 7 | method: 'POST', 8 | body: JSON.stringify({ 9 | theme, 10 | }) 11 | }) 12 | 13 | const data: { id: string } = await response.json() 14 | 15 | return { roomId: data.id } 16 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | AMA | Ask my anything 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/http/create-message.ts: -------------------------------------------------------------------------------- 1 | interface CreateMessageRequest { 2 | roomId: string 3 | message: string 4 | } 5 | 6 | export async function createMessage({ roomId, message }: CreateMessageRequest) { 7 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages`, { 8 | method: 'POST', 9 | body: JSON.stringify({ 10 | message, 11 | }) 12 | }) 13 | 14 | const data: { id: string } = await response.json() 15 | 16 | return { messageId: data.id } 17 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from 'sonner' 2 | import { createBrowserRouter, RouterProvider } from 'react-router-dom' 3 | import { CreateRoom } from './pages/create-room' 4 | import { Room } from './pages/room' 5 | import { QueryClientProvider } from '@tanstack/react-query' 6 | import { queryClient } from './lib/react-query' 7 | 8 | const router = createBrowserRouter([ 9 | { 10 | path: '/', 11 | element: 12 | }, 13 | { 14 | path: '/room/:roomId', 15 | element: 16 | } 17 | ]) 18 | 19 | export function App() { 20 | return ( 21 | 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | "target": "ES2020", 6 | "useDefineForClassFields": true, 7 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /src/http/get-room-messages.ts: -------------------------------------------------------------------------------- 1 | interface GetRoomMessagesRequest { 2 | roomId: string 3 | } 4 | 5 | export interface GetRoomMessagesResponse { 6 | messages: { 7 | id: string; 8 | text: string; 9 | amountOfReactions: number; 10 | answered: boolean; 11 | }[] 12 | } 13 | 14 | export async function getRoomMessages({ roomId }: GetRoomMessagesRequest): Promise { 15 | const response = await fetch(`${import.meta.env.VITE_APP_API_URL}/rooms/${roomId}/messages`) 16 | 17 | const data: Array<{ 18 | id: string 19 | room_id: string 20 | message: string 21 | reaction_count: number 22 | answered: boolean 23 | }> = await response.json() 24 | 25 | return { 26 | messages: data.map(item => { 27 | return { 28 | id: item.id, 29 | text: item.message, 30 | amountOfReactions: item.reaction_count, 31 | answered: item.answered, 32 | } 33 | }) 34 | } 35 | } -------------------------------------------------------------------------------- /src/components/messages.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import { Message } from "./message"; 3 | import { getRoomMessages } from "../http/get-room-messages"; 4 | import { useSuspenseQuery } from "@tanstack/react-query"; 5 | import { useMessagesWebSockets } from "../hooks/use-messages-web-sockets"; 6 | 7 | export function Messages() { 8 | const { roomId } = useParams() 9 | 10 | if (!roomId) { 11 | throw new Error('Messages components must be used within room page') 12 | } 13 | 14 | const { data } = useSuspenseQuery({ 15 | queryKey: ['messages', roomId], 16 | queryFn: () => getRoomMessages({ roomId }), 17 | }) 18 | 19 | useMessagesWebSockets({ roomId }) 20 | 21 | const sortedMessages = data.messages.sort((a, b) => { 22 | return b.amountOfReactions - a.amountOfReactions 23 | }) 24 | 25 | return ( 26 |
    27 | {sortedMessages.map(message => { 28 | return ( 29 | 36 | ) 37 | })} 38 |
39 | ) 40 | } -------------------------------------------------------------------------------- /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 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tanstack/react-query": "5.51.17", 14 | "lucide-react": "0.417.0", 15 | "react": "19.0.0-rc-3208e73e-20240730", 16 | "react-dom": "19.0.0-rc-3208e73e-20240730", 17 | "react-router-dom": "6.25.1", 18 | "sonner": "1.5.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "npm:types-react@rc", 22 | "@types/react-dom": "npm:types-react-dom@rc", 23 | "@typescript-eslint/eslint-plugin": "^7.15.0", 24 | "@typescript-eslint/parser": "^7.15.0", 25 | "@vitejs/plugin-react": "^4.3.1", 26 | "autoprefixer": "10.4.19", 27 | "eslint": "^8.57.0", 28 | "eslint-plugin-react-hooks": "^4.6.2", 29 | "eslint-plugin-react-refresh": "^0.4.7", 30 | "postcss": "8.4.40", 31 | "tailwindcss": "3.4.7", 32 | "typescript": "^5.2.2", 33 | "vite": "^5.3.4" 34 | }, 35 | "overrides": { 36 | "@types/react": "npm:types-react@rc", 37 | "@types/react-dom": "npm:types-react-dom@rc" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/create-message-form.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight } from "lucide-react"; 2 | import { useParams } from "react-router-dom"; 3 | import { createMessage } from "../http/create-message"; 4 | import { toast } from "sonner"; 5 | 6 | export function CreateMessageForm() { 7 | const { roomId } = useParams() 8 | 9 | if (!roomId) { 10 | throw new Error('Messages components must be used within room page') 11 | } 12 | 13 | async function createMessageAction(data: FormData) { 14 | const message = data.get('message')?.toString() 15 | 16 | if (!message || !roomId) { 17 | return 18 | } 19 | 20 | try { 21 | await createMessage({ message, roomId }) 22 | } catch { 23 | toast.error('Falha ao enviar pergunta, tente novamente!') 24 | } 25 | } 26 | 27 | return ( 28 |
32 | 40 | 41 | 48 |
49 | ) 50 | } -------------------------------------------------------------------------------- /src/pages/room.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom" 2 | import { Share2 } from "lucide-react" 3 | import { toast } from "sonner" 4 | 5 | import amaLogo from '../assets/ama-logo.svg' 6 | import { Messages } from "../components/messages" 7 | import { Suspense } from "react" 8 | import { CreateMessageForm } from "../components/create-message-form" 9 | 10 | export function Room() { 11 | const { roomId } = useParams() 12 | 13 | function handleShareRoom() { 14 | const url = window.location.href.toString() 15 | 16 | if (navigator.share !== undefined && navigator.canShare()) { 17 | navigator.share({ url }) 18 | } else { 19 | navigator.clipboard.writeText(url) 20 | 21 | toast.info('O link da sala foi copiado para área de transferência!') 22 | } 23 | } 24 | 25 | return ( 26 |
27 |
28 | AMA 29 | 30 | 31 | Código da sala: {roomId} 32 | 33 | 34 | 42 |
43 | 44 |
45 | 46 | 47 | 48 | Carregando...

}> 49 | 50 |
51 |
52 | ) 53 | } -------------------------------------------------------------------------------- /src/pages/create-room.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight } from 'lucide-react' 2 | import { toast } from 'sonner' 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | import amaLogo from '../assets/ama-logo.svg' 6 | import { createRoom } from '../http/create-room' 7 | 8 | export function CreateRoom() { 9 | const navigate = useNavigate() 10 | 11 | async function handleCreateRoom(data: FormData) { 12 | const theme = data.get('theme')?.toString() 13 | 14 | if (!theme) { 15 | return 16 | } 17 | 18 | try { 19 | const { roomId } = await createRoom({ theme }) 20 | 21 | navigate(`/room/${roomId}`) 22 | } catch { 23 | toast.error('Falha ao criar sala!') 24 | } 25 | } 26 | 27 | return ( 28 |
29 |
30 | AMA 31 | 32 |

33 | Crie uma sala pública de AMA (Ask me anything) e priorize as perguntas mais importantes para a comunidade. 34 |

35 | 36 |
40 | 48 | 49 | 56 |
57 |
58 |
59 | ) 60 | } -------------------------------------------------------------------------------- /src/components/message.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowUp } from "lucide-react"; 2 | import { useState } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import { createMessageReaction } from "../http/create-message-reaction"; 5 | import { toast } from "sonner"; 6 | import { removeMessageReaction } from "../http/remove-message-reaction"; 7 | 8 | interface MessageProps { 9 | id: string 10 | text: string 11 | amountOfReactions: number 12 | answered?: boolean 13 | } 14 | 15 | export function Message({ 16 | id: messageId, 17 | text, 18 | amountOfReactions, 19 | answered = false, 20 | }: MessageProps) { 21 | const { roomId } = useParams() 22 | const [hasReacted, setHasReacted] = useState(false) 23 | 24 | if (!roomId) { 25 | throw new Error('Messages components must be used within room page') 26 | } 27 | 28 | async function createMessageReactionAction() { 29 | if (!roomId) { 30 | return 31 | } 32 | 33 | try { 34 | await createMessageReaction({ messageId, roomId }) 35 | } catch { 36 | toast.error('Falha ao reagir mensagem, tente novamente!') 37 | } 38 | 39 | setHasReacted(true) 40 | } 41 | 42 | async function removeMessageReactionAction() { 43 | if (!roomId) { 44 | return 45 | } 46 | 47 | try { 48 | await removeMessageReaction({ messageId, roomId }) 49 | } catch { 50 | toast.error('Falha ao remover reação, tente novamente!') 51 | } 52 | 53 | setHasReacted(false) 54 | } 55 | 56 | return ( 57 |
  • 58 | {text} 59 | 60 | {hasReacted ? ( 61 | 69 | ) : ( 70 | 78 | )} 79 |
  • 80 | ) 81 | } -------------------------------------------------------------------------------- /src/assets/ama-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/hooks/use-messages-web-sockets.ts: -------------------------------------------------------------------------------- 1 | import { useQueryClient } from "@tanstack/react-query" 2 | import { useEffect } from "react" 3 | import type { GetRoomMessagesResponse } from "../http/get-room-messages" 4 | 5 | interface useMessagesWebSocketsParams { 6 | roomId: string 7 | } 8 | 9 | type WebhookMessage = 10 | | { kind: "message_created"; value: { id: string, message: string } } 11 | | { kind: "message_answered"; value: { id: string } } 12 | | { kind: "message_reaction_increased"; value: { id: string; count: number } } 13 | | { kind: "message_reaction_decreased"; value: { id: string; count: number } }; 14 | 15 | export function useMessagesWebSockets({ 16 | roomId, 17 | }: useMessagesWebSocketsParams) { 18 | const queryClient = useQueryClient() 19 | 20 | useEffect(() => { 21 | const ws = new WebSocket(`ws://localhost:8080/subscribe/${roomId}`) 22 | 23 | ws.onopen = () => { 24 | console.log('Websocket connected!') 25 | } 26 | 27 | ws.onclose = () => { 28 | console.log('Websocket connection closed!') 29 | } 30 | 31 | ws.onmessage = (event) => { 32 | const data: WebhookMessage = JSON.parse(event.data) 33 | 34 | switch(data.kind) { 35 | case 'message_created': 36 | queryClient.setQueryData(['messages', roomId], state => { 37 | return { 38 | messages: [ 39 | ...(state?.messages ?? []), 40 | { 41 | id: data.value.id, 42 | text: data.value.message, 43 | amountOfReactions: 0, 44 | answered: false, 45 | } 46 | ], 47 | } 48 | }) 49 | 50 | break; 51 | case 'message_answered': 52 | queryClient.setQueryData(['messages', roomId], state => { 53 | if (!state) { 54 | return undefined 55 | } 56 | 57 | return { 58 | messages: state.messages.map(item => { 59 | if (item.id === data.value.id) { 60 | return { ...item, answered: true } 61 | } 62 | 63 | return item 64 | }), 65 | } 66 | }) 67 | 68 | break; 69 | case 'message_reaction_increased': 70 | case 'message_reaction_decreased': 71 | queryClient.setQueryData(['messages', roomId], state => { 72 | if (!state) { 73 | return undefined 74 | } 75 | 76 | return { 77 | messages: state.messages.map(item => { 78 | if (item.id === data.value.id) { 79 | return { ...item, amountOfReactions: data.value.count } 80 | } 81 | 82 | return item 83 | }), 84 | } 85 | }) 86 | 87 | break; 88 | } 89 | } 90 | 91 | return () => { 92 | ws.close() 93 | } 94 | }, [roomId, queryClient]) 95 | } --------------------------------------------------------------------------------