diff --git a/package-lock.json b/package-lock.json index cdc8658..f03c3b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,21 @@ "name": "web-scrapper-pro-client", "version": "0.1.0", "dependencies": { + "@microsoft/signalr": "^10.0.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^13.5.0", + "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-scripts": "5.0.1", "web-vitals": "^2.1.4" + }, + "devDependencies": { + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19" } }, "node_modules/@adobe/css-tools": { @@ -2858,6 +2865,19 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "license": "MIT" }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4206,6 +4226,18 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead", "license": "BSD-3-Clause" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -7676,6 +7708,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -7691,6 +7732,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7890,6 +7940,16 @@ "bser": "2.1.1" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -11095,6 +11155,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -11423,6 +11492,48 @@ "tslib": "^2.0.3" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", @@ -14581,6 +14692,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 1fb212f..08b9dd4 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,12 @@ "version": "0.1.0", "private": true, "dependencies": { + "@microsoft/signalr": "^10.0.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^13.5.0", + "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-scripts": "5.0.1", @@ -35,5 +37,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19" } } diff --git a/src/App.js b/src/App.js index 3784575..cb46226 100644 --- a/src/App.js +++ b/src/App.js @@ -1,25 +1,553 @@ -import logo from './logo.svg'; -import './App.css'; +import React, { useState, useEffect, useRef } from 'react'; +import { Database, Globe, Download, Play, Pause, Trash2, Plus, RefreshCw, Activity, Clock, CircleX, CircleCheck } from 'lucide-react'; +import * as signalR from '@microsoft/signalr'; + +const API_BASE = 'http://localhost:5123'; + +const WebScraper = () => { + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState(null); + const selectedSession = sessions.find(s => s.sessionId === selectedSessionId) ?? null; + const [sessionStatus, setSessionStatus] = useState(null); + const [newUrl, setNewUrl] = useState(''); + const [logs, setLogs] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + const connectionRef = useRef(null); + + const addLog = (message, type = 'info') => { + setLogs(prev => { + const newLogs = [...prev, { + message, + type, + time: new Date().toLocaleTimeString() + }]; + return newLogs.slice(-100); + }); + }; + + useEffect(() => { + loadSessions(); + }, []); + + // Conectar ao SignalR + useEffect(() => { + const connection = new signalR.HubConnectionBuilder() + .withUrl(`http://localhost:5123/ws/scrape`) + .withAutomaticReconnect() + .build(); + + connection.on('scrapeEvent', (event) => { + handleScrapeEvent(normalizeEventType(event)); + }); + + connection.onreconnecting(() => { + setIsConnected(false); + addLog('Reconectando ao servidor...', 'warning'); + }); + + connection.onreconnected(async () => { + setIsConnected(true); + addLog('Reconectado ao servidor', 'success'); + + try { + await connection.invoke('SubscribeOverview'); + await loadSessions(); // ressincroniza snapshot + if (selectedSession) { + await connection.invoke('Subscribe', selectedSession.sessionId); + await loadSessionStatus(selectedSession.sessionId); // snapshot também + } + } catch (e) { } + }); + + connection.onclose(() => { + setIsConnected(false); + addLog('Desconectado do servidor', 'error'); + }); + + connection.start() + .then(async () => { + setIsConnected(true); + addLog('Conectado ao servidor', 'success'); + connectionRef.current = connection; + + // ✅ NOVO: assinar overview + await connection.invoke('SubscribeOverview'); + + // pega snapshot inicial uma vez + await loadSessions(); + }) + .catch(err => { + addLog(`Erro ao conectar: ${err.message}`, 'error'); + }); + + return () => { + if (connection) { + connection.stop(); + } + }; + }, []); + + // Inscrever na sessão selecionada + useEffect(() => { + if (connectionRef.current && selectedSession && isConnected) { + connectionRef.current.invoke('Subscribe', selectedSession.sessionId) + .then(() => { + addLog(`Inscrito na sessão: ${selectedSession.name}`, 'info'); + }) + .catch(err => { + addLog(`Erro ao inscrever: ${err.message}`, 'error'); + }); + + return () => { + if (connectionRef.current) { + connectionRef.current.invoke('Unsubscribe', selectedSession.sessionId) + .catch(() => { }); + } + }; + } + }, [selectedSession, isConnected]); + + const upsertSessionFromEvent = (sid, patch) => { + setSessions(prev => { + const idx = prev.findIndex(s => s.sessionId === sid); + if (idx === -1) return prev; // ou: return [...prev, {sessionId:sid, name:'', ...patch}] + const copy = [...prev]; + copy[idx] = { ...copy[idx], ...patch }; + return copy; + }); + }; + + const ScrapeEventType = { + 0: 'SessionStarted', + 1: 'SessionStopRequested', + 2: 'SessionStopped', + 3: 'ItemStarted', + 4: 'ItemSucceeded', + 5: 'ItemFailed', + 6: 'Progress', + }; + + const normalizeEventType = (ev) => { + if (typeof ev.type === 'number') return { ...ev, type: ScrapeEventType[ev.type] ?? ev.type }; + return ev; + }; + + const handleScrapeEvent = (event) => { + const sid = event.sessionId; + + switch (event.type) { + case 'SessionStarted': + upsertSessionFromEvent(event.sessionId, { isRunning: true }); + addLog(`🚀 Sessão iniciada`, 'success'); + break; + + case 'SessionStopped': + upsertSessionFromEvent(event.sessionId, { isRunning: false }); + addLog(`⏹️ Sessão parada`, 'info'); + break; + + case 'ItemStarted': + addLog(`📄 Processando: ${event.url}`, 'info'); + + // Atualiza runtime do painel se essa for a sessão selecionada + setSessionStatus(prev => { + if (!prev || prev.runtime?.sessionId !== sid) return prev; + + return { + ...prev, + runtime: { + ...prev.runtime, + isRunning: true, + currentUrl: event.url, + currentQueueId: event.queueId ?? prev.runtime?.currentQueueId, + currentStartedAt: event.at + } + }; + }); + break; + + case 'ItemSucceeded': + addLog(`✓ Sucesso: ${event.url}`, 'success'); + break; + + case 'ItemFailed': + addLog(`✗ Falha: ${event.url} - ${event.error}`, 'error'); + break; + + case 'Progress': { + upsertSessionFromEvent(event.sessionId, { + queue: { + total: event.total, + pending: event.pending, + processing: event.processing, + done: event.done, + failed: event.failed, + } + }); + + // também atualiza painel se for a sessão selecionada + if (selectedSession?.sessionId === event.sessionId) { + setSessionStatus(prev => ({ + ...(prev ?? {}), + counts: { + total: event.total, + pending: event.pending, + processing: event.processing, + done: event.done, + failed: event.failed + }, + percent: event.percent + })); + } + break; + } + + default: + break; + } + }; + + const loadSessions = async () => { + try { + const response = await fetch(`${API_BASE}/sessions`); + const data = await response.json(); + setSessions(data); + } catch (err) { + addLog(`Erro ao carregar sessões: ${err.message}`, 'error'); + } + }; + + const loadSessionStatus = async (sessionId) => { + try { + const response = await fetch(`${API_BASE}/sessions/${sessionId}/scrap/status`); + const data = await response.json(); + setSessionStatus(data); + } catch (err) { + addLog(`Erro ao carregar status: ${err.message}`, 'error'); + } + }; + + const startScraping = async () => { + if (!selectedSession) return; + + try { + await fetch(`${API_BASE}/sessions/${selectedSession.sessionId}/scrap/start`, { + method: 'POST' + }); + addLog(`Iniciando scraping da sessão: ${selectedSession.name}`, 'info'); + } catch (err) { + addLog(`Erro ao iniciar: ${err.message}`, 'error'); + } + }; + + const stopScraping = async () => { + if (!selectedSession) return; + + try { + await fetch(`${API_BASE}/sessions/${selectedSession.sessionId}/scrap/stop`, { + method: 'POST' + }); + addLog(`Parando sessão: ${selectedSession.name}`, 'warning'); + } catch (err) { + addLog(`Erro ao parar: ${err.message}`, 'error'); + } + }; + + const addUrlToQueue = async () => { + if (!selectedSession || !newUrl) return; + + try { + const response = await fetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: newUrl }) + }); + + if (response.ok) { + const data = await response.json(); + addLog(`✓ URL adicionada: ${newUrl}`, 'success'); + setNewUrl(''); + loadSessionStatus(selectedSession.sessionId); + } else { + const error = await response.text(); + addLog(`Erro ao adicionar URL: ${error}`, 'error'); + } + } catch (err) { + addLog(`Erro ao adicionar URL: ${err.message}`, 'error'); + } + }; + + const updateSessionFromProgress = (sessionId, ev) => { + setSessions(prev => + prev.map(s => + s.sessionId !== sessionId + ? s + : { + ...s, + queue: { + total: ev.total ?? s.queue?.total ?? 0, + pending: ev.pending ?? s.queue?.pending ?? 0, + processing: ev.processing ?? s.queue?.processing ?? 0, + done: ev.done ?? s.queue?.done ?? 0, + failed: ev.failed ?? s.queue?.failed ?? 0 + } + } + ) + ); + }; + + const selectSession = (session) => { + setSelectedSessionId(session.sessionId); + setLogs([]); + addLog(`Sessão selecionada: ${session.name}`, 'info'); + loadSessionStatus(session.sessionId); // snapshot inicial + }; + + const formatNumber = (num) => { + return num?.toLocaleString('pt-BR') || '0'; + }; + + const patchSessionCounts = (sid, ev) => { + setSessions(prev => + prev.map(s => s.sessionId !== sid ? s : ({ + ...s, + queue: { + total: ev.total ?? s.queue?.total ?? 0, + pending: ev.pending ?? s.queue?.pending ?? 0, + processing: ev.processing ?? s.queue?.processing ?? 0, + done: ev.done ?? s.queue?.done ?? 0, + failed: ev.failed ?? s.queue?.failed ?? 0 + } + })) + ); + }; + + const patchSessionRunning = (sid, isRunning) => { + setSessions(prev => + prev.map(s => s.sessionId !== sid ? s : ({ ...s, isRunning })) + ); + }; + + const getProgressPercent = () => { + if (!sessionStatus?.counts) return 0; + const { total, done } = sessionStatus.counts; + return total > 0 ? ((done / total) * 100).toFixed(1) : 0; + }; -function App() { return ( -
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
+
+
+ {/* Header */} +
+
+

+ + Web Scraper Pro +

+

Gerenciador de scraping com API backend

+
+
+
+ + {isConnected ? 'Conectado' : 'Desconectado'} +
+ +
+
+ +
+ {/* Lista de Sessões */} +
+

+ + Sessões +

+
+ {sessions.map((session) => ( +
selectSession(session)} + className={`p-4 rounded-lg cursor-pointer transition ${selectedSession?.sessionId === session.sessionId + ? 'bg-blue-600' + : 'bg-slate-700 hover:bg-slate-600' + }`} + > +
+ {session.name} + {session.isRunning && ( + + Rodando + + )} +
+
+
Total: {formatNumber(session.queue?.total || 0)}
+
+  {formatNumber(session.queue?.done || 0)} +  {formatNumber(session.queue?.pending || 0)} +  {formatNumber(session.queue?.failed || 0)} +
+
+
+ ))} + {sessions.length === 0 && ( +
+ Nenhuma sessão disponível +
+ )} +
+
+ + {/* Detalhes e Controles */} +
+ {selectedSession ? ( + <> + {/* Controles */} +
+

Controles - {selectedSession.name}

+ +
+ setNewUrl(e.target.value)} + placeholder="https://site.com/page" + className="flex-1 px-4 py-2 bg-slate-700 rounded-lg border border-slate-600 focus:border-blue-500 focus:outline-none" + onKeyPress={(e) => e.key === 'Enter' && addUrlToQueue()} + /> + +
+ +
+ {!selectedSession.isRunning ? ( + + ) : ( + + )} +
+
+ + {/* Estatísticas */} + {sessionStatus && ( + <> +
+

Progresso

+ + {/* Barra de Progresso */} +
+
+ Processado + {getProgressPercent()}% +
+
+
+
+
+ +
+
+
Total
+
{formatNumber(sessionStatus.counts?.total)}
+
+
+
Concluído
+
{formatNumber(sessionStatus.counts?.done)}
+
+
+
Pendente
+
{formatNumber(sessionStatus.counts?.pending)}
+
+
+
Processando
+
{formatNumber(sessionStatus.counts?.processing)}
+
+
+
Falhas
+
{formatNumber(sessionStatus.counts?.failed)}
+
+
+ + {/* URL Atual */} + {sessionStatus.runtime?.isRunning && sessionStatus.runtime?.currentUrl && ( +
+
Processando agora:
+
+ {sessionStatus.runtime.currentUrl} +
+
+ Iniciado: {new Date(sessionStatus.runtime.currentStartedAt).toLocaleTimeString('pt-BR')} +
+
+ )} +
+ + )} + + {/* Logs */} +
+

Logs em Tempo Real

+
+ {logs.map((log, idx) => ( +
+ [{log.time}] {log.message} +
+ ))} + {logs.length === 0 && ( +
+ Nenhum evento ainda. Selecione uma sessão e inicie o scraping. +
+ )} +
+
+ + ) : ( +
+ +

Selecione uma Sessão

+

+ Escolha uma sessão à esquerda para começar +

+
+ )} +
+
+
); -} +}; -export default App; +export default WebScraper; \ No newline at end of file diff --git a/src/App.test.js b/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/src/index.css b/src/index.css index ec2585e..bd6213e 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,3 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/src/index.js b/src/index.js index d563c0f..77ef353 100644 --- a/src/index.js +++ b/src/index.js @@ -2,16 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; -import reportWebVitals from './reportWebVitals'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + // - -); - -// If you want to start measuring performance in your app, pass a function -// to log results (for example: reportWebVitals(console.log)) -// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals(); + // +); \ No newline at end of file diff --git a/src/logo.svg b/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js deleted file mode 100644 index 5253d3a..0000000 --- a/src/reportWebVitals.js +++ /dev/null @@ -1,13 +0,0 @@ -const reportWebVitals = onPerfEntry => { - if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); - }); - } -}; - -export default reportWebVitals; diff --git a/src/setupTests.js b/src/setupTests.js deleted file mode 100644 index 8f2609b..0000000 --- a/src/setupTests.js +++ /dev/null @@ -1,5 +0,0 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..b7c41d1 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,9 @@ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file