├── .env ├── .gitignore ├── .vercel ├── README.txt └── project.json ├── .vscode └── settings.json ├── Procfile ├── README.md ├── api ├── README.md ├── authMiddleware.ts ├── favs.ts ├── routes.ts ├── server.ts ├── userMiddleware.ts └── users.ts ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo-old.png ├── logo.png ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.js ├── App.test.js ├── __tests__ │ └── index.js ├── components │ ├── Button │ │ ├── index.js │ │ └── styles.js │ ├── Category │ │ ├── Category.css │ │ ├── index.js │ │ └── styles.js │ ├── Fav │ │ ├── Fav.css │ │ └── index.js │ ├── Gif │ │ ├── Gif.css │ │ └── index.js │ ├── Header │ │ ├── Header.css │ │ └── index.js │ ├── ListOfGifs │ │ ├── ListOfGifs.css │ │ └── index.js │ ├── Login │ │ ├── Login.css │ │ └── index.js │ ├── Modal │ │ ├── Modal.css │ │ └── index.js │ ├── Register │ │ ├── index.js │ │ └── indexWithFormik.js │ ├── SearchForm │ │ ├── SearchForm.module.css │ │ ├── hook.js │ │ ├── hook.test.js │ │ └── index.js │ ├── Spinner │ │ ├── index.js │ │ └── styles.css │ └── TrendingSearches │ │ ├── TrendingSearches.js │ │ └── index.js ├── context │ ├── GifsContext.js │ ├── StaticContext.js │ └── UserContext.js ├── hooks │ ├── useGifs.js │ ├── useGlobalGifs.js │ ├── useNearScreen.js │ ├── useSEO.js │ ├── useSingleGif.js │ └── useUser.js ├── index.css ├── index.js ├── logo.svg ├── pages │ ├── Detail │ │ └── index.js │ ├── ErrorPage │ │ └── index.js │ ├── Home │ │ └── index.js │ ├── Login │ │ └── index.js │ ├── Register │ │ └── index.js │ └── SearchResults │ │ └── index.js ├── serviceWorker.js ├── services │ ├── addFav.js │ ├── getFavs.js │ ├── getGifs.js │ ├── getSingleGif.js │ ├── getTrendingTermsService.js │ ├── login.js │ ├── register.js │ └── settings.js ├── setupTests.js └── styles │ └── index.js └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | JWT_KEY = some-random-key -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.vercel/README.txt: -------------------------------------------------------------------------------- 1 | > Why do I have a folder named ".vercel" in my project? 2 | The ".vercel" folder is created when you link a directory to a Vercel project. 3 | 4 | > What does the "project.json" file contain? 5 | The "project.json" file contains: 6 | - The ID of the Vercel project that you linked ("projectId") 7 | - The ID of the user or team your Vercel project is owned by ("orgId") 8 | 9 | > Should I commit the ".vercel" folder? 10 | No, you should not share the ".vercel" folder with anyone. 11 | Upon creation, it will be automatically added to your ".gitignore" file. 12 | -------------------------------------------------------------------------------- /.vercel/project.json: -------------------------------------------------------------------------------- 1 | {"orgId":"NaxyJkv7s4Fi5ZGgseFLbKsJ","projectId":"QmS5QHYHsafbxrVC2RKegP2mBw6UnR3LEh4xsHk8pgjHkt"} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { "deno.enable": false} -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: deno run --allow-net=:${PORT} --allow-read --allow-env api/server.ts --port=${PORT} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Curso de React ⚛️ desde cero gratuito 2020 2 | 3 | ### [1. Aprendiendo React desde cero](https://youtu.be/T_j60n1zgu0) 4 | [![Aprendiendo React desde cero](https://img.youtube.com/vi/T_j60n1zgu0/mqdefault.jpg)](https://youtu.be/T_j60n1zgu0) 5 | 6 | ### [2. Crea un app con create-react-app](https://youtu.be/QBLbXgeXMU8) 7 | [![Crea un app con create-react-app](http://img.youtube.com/vi/QBLbXgeXMU8/mqdefault.jpg)](https://youtu.be/QBLbXgeXMU8) 8 | 9 | ### [3. Custom Hooks y React Context](https://youtu.be/2qgs7buSnHQ) 10 | [![Crea un app con create-react-app](http://img.youtube.com/vi/2qgs7buSnHQ/mqdefault.jpg)](https://youtu.be/2qgs7buSnHQ) 11 | 12 | ### [4. Lazy Load, Suspense y Paginación](https://youtu.be/VcxXipZg1-0) 13 | [![Lazy Load, Suspense y Paginación](http://img.youtube.com/vi/VcxXipZg1-0/mqdefault.jpg)](https://youtu.be/VcxXipZg1-0) 14 | 15 | ### [5. CSS Grid, Infinite Scroll y Testing](https://youtu.be/oCHdFiCgOSE) 16 | [![Lazy Load, Suspense y Paginación](http://img.youtube.com/vi/oCHdFiCgOSE/mqdefault.jpg)](https://youtu.be/oCHdFiCgOSE) 17 | 18 | ### [6. React.memo y Deploy con Vercel](https://youtu.be/Wo7_OVtu1ls) 19 | [![React.memo y Deploy con Vercel](http://img.youtube.com/vi/Wo7_OVtu1ls/mqdefault.jpg)](https://youtu.be/Wo7_OVtu1ls) 20 | 21 | ### [7. SEO con React y Deploy integrado con GitHub](https://youtu.be/b-pwpHaYOTI) 22 | [![SEO con React y Deploy integrado con GitHub](http://img.youtube.com/vi/b-pwpHaYOTI/mqdefault.jpg)](https://youtu.be/b-pwpHaYOTI) 23 | 24 | ### [8. useReducer y testing de Hooks](https://youtu.be/Wjy_nlYXTik) 25 | [![SEO con React y Deploy integrado con GitHub](http://img.youtube.com/vi/Wjy_nlYXTik/mqdefault.jpg)](https://youtu.be/Wjy_nlYXTik) 26 | 27 | ### [9. Login y Sesión de Usuarios para guardar Favoritos en React](https://youtu.be/VT5S9Y49SYs) 28 | [![Login y Sesión de Usuarios para guardar Favoritos en React](http://img.youtube.com/vi/VT5S9Y49SYs/mqdefault.jpg)](https://youtu.be/VT5S9Y49SYs) 29 | 30 | ### [10. Registro de usuario con Formik, React.createPortal y gestión de favoritos en nuestra app de ReactJS](https://www.youtube.com/watch?v=dtbI6gDnTFU) 31 | [![Registro de usuario con Formik, React.createPortal y gestión de favoritos en nuestra app de ReactJS](http://img.youtube.com/vi/dtbI6gDnTFU/mqdefault.jpg)](https://youtu.be/dtbI6gDnTFU) 32 | 33 | ### [11. CSS en JS y Styled Components con Emotion](https://www.youtube.com/watch?v=DjVGdUM1dHQ) 34 | [![CSS en JS y Styled Components con Emotion](http://img.youtube.com/vi/DjVGdUM1dHQ/mqdefault.jpg)](https://youtu.be/DjVGdUM1dHQ) 35 | 36 | ### [¿Quieres más videos sobre frontend? ¡Suscríbete a mi canal!](https://www.youtube.com/c/midudev?sub_confirmation=1) 37 | [![canal de midudev de Youtube](https://yt3.ggpht.com/a/AATXAJzuyjCt8K0QD8x_PrTB11LTlvpX2iVWk4eCSQ=s176-c-k-c0xffffffff-no-rj-mo)](https://www.youtube.com/c/midudev?sub_confirmation=1) 38 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # Api de Usuarios con Deno 🦕 2 | 3 | Para ejecutar en local esta API necesitas tener instada la última versión de Deno y ejecutar dentro de la carpeta `/api`: 4 | 5 | ```js 6 | deno run --allow-net=:8080 --allow-read --allow-env server.ts --port=8080 7 | ``` -------------------------------------------------------------------------------- /api/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "https://deno.land/x/oak@v5.0.0/mod.ts" 2 | 3 | const authMiddleware = async (ctx: Context, next: Function) => { 4 | if (ctx.state.currentUser) { 5 | await next() 6 | } else { 7 | ctx.response.status = 405 8 | } 9 | } 10 | 11 | export {authMiddleware} -------------------------------------------------------------------------------- /api/favs.ts: -------------------------------------------------------------------------------- 1 | export const favs: any = { 2 | 'midudev': [] 3 | } -------------------------------------------------------------------------------- /api/routes.ts: -------------------------------------------------------------------------------- 1 | import { RouterContext } from "https://deno.land/x/oak@v5.0.0/mod.ts"; 2 | import { hashSync, compareSync } from "https://deno.land/x/bcrypt@v0.2.1/mod.ts"; 3 | import { makeJwt, setExpiration, Jose } from "https://deno.land/x/djwt@v0.9.0/create.ts"; 4 | import { users, User } from './users.ts'; 5 | import { favs } from './favs.ts' 6 | 7 | 8 | const header: Jose = { 9 | alg: 'HS256', 10 | typ: 'JWT' 11 | }; 12 | 13 | export const getFavs = async (ctx: RouterContext) => { 14 | const {username} = ctx.state.currentUser 15 | ctx.response.status = 200 16 | ctx.response.body = { favs: favs[username] } 17 | } 18 | 19 | export const deleteFav = async (ctx: RouterContext) => { 20 | const {id} = ctx.params 21 | const {username} = ctx.state.currentUser 22 | favs[username] = favs[username].filter( 23 | (favId : string) => favId !== id 24 | ) 25 | 26 | console.log({ 27 | idRemoved: id, 28 | remainingFavs: favs[username], 29 | username, 30 | }) 31 | 32 | ctx.response.body = { favs: favs[username] } 33 | ctx.response.status = 200 34 | } 35 | 36 | export const postFav = async (ctx: RouterContext) => { 37 | const {id} = ctx.params 38 | const {username} = ctx.state.currentUser 39 | 40 | const alreadyExist = favs[username].some( 41 | (favId : string) => favId === id 42 | ) 43 | if (!alreadyExist) { 44 | favs[username].push(id) 45 | } 46 | 47 | console.log({ 48 | alreadyExist, 49 | favs: favs[username], 50 | username, 51 | }) 52 | 53 | ctx.response.body = { favs: favs[username] } 54 | ctx.response.status = 201 55 | } 56 | 57 | export const postLogin = async (ctx: RouterContext) => { 58 | const { value } = await ctx.request.body(); 59 | const {username, password} = value 60 | 61 | const user: any = users.find((u: User) => u.username === username); 62 | 63 | if (!user) { 64 | ctx.response.status = 403 65 | } else if (!compareSync(password, user.password)) { 66 | ctx.response.status = 403 67 | } else { 68 | const payload = { 69 | iss: user.username, 70 | exp: setExpiration(Date.now() + 1000 * 60 * 60) 71 | }; 72 | const jwt = makeJwt({ 73 | key: Deno.env.get('JWT_KEY') || '', 74 | header, 75 | payload 76 | }) 77 | ctx.response.status = 201 78 | ctx.response.body = {jwt} 79 | } 80 | } 81 | 82 | export const postRegister = async (ctx: RouterContext) => { 83 | const { value } = await ctx.request.body(); 84 | const {username, password} = value 85 | 86 | const hashedPassword = hashSync(password); 87 | 88 | const user: User = { 89 | username, 90 | password: hashedPassword, 91 | }; 92 | 93 | // TODO: Check it doesn't exist yet 94 | const alreadyExist = users.find(user => user.username === username) 95 | if (alreadyExist) { 96 | ctx.response.status = 409 97 | } else { 98 | users.push(user); 99 | // initialize the user favs 100 | favs[username] = []; 101 | ctx.response.status = 201 102 | } 103 | 104 | } -------------------------------------------------------------------------------- /api/server.ts: -------------------------------------------------------------------------------- 1 | import { Application, Router } from "https://deno.land/x/oak@v5.0.0/mod.ts" 2 | import { oakCors } from "https://deno.land/x/cors/mod.ts" 3 | import "https://deno.land/x/dotenv@v0.4.1/load.ts" 4 | import * as flags from 'https://deno.land/std/flags/mod.ts' 5 | 6 | import { userMiddleware } from "./userMiddleware.ts" 7 | import { authMiddleware } from "./authMiddleware.ts" 8 | import { 9 | getFavs, 10 | deleteFav, 11 | postFav, 12 | postLogin, 13 | postRegister 14 | } from "./routes.ts" 15 | 16 | const {args} = Deno 17 | 18 | const DEFAULT_PORT = 8080 19 | const portFromArgs = flags.parse(args).port 20 | const port = portFromArgs ? Number(portFromArgs) : DEFAULT_PORT 21 | 22 | const app = new Application() 23 | const router = new Router() 24 | 25 | app.use(userMiddleware, oakCors()) 26 | 27 | router 28 | .get('/favs', authMiddleware, getFavs) 29 | .delete("/favs/:id", authMiddleware, deleteFav) 30 | .post("/favs/:id", authMiddleware, postFav) 31 | .post("/login", postLogin) 32 | .post("/register", postRegister) 33 | 34 | app.addEventListener('error', evt => { 35 | console.log(evt.error) 36 | }) 37 | 38 | app.use(router.routes()) 39 | app.use(router.allowedMethods()) 40 | 41 | app.listen({ port }) 42 | console.log(`Started listening on port: ${port}`) 43 | -------------------------------------------------------------------------------- /api/userMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "https://deno.land/x/oak@v5.0.0/mod.ts"; 2 | import { validateJwt } from "https://deno.land/x/djwt/validate.ts" 3 | import { users, User } from "./users.ts"; 4 | 5 | const userMiddleware = async (ctx: Context, next: Function) => { 6 | // Get JWT from request if available 7 | const { value = {} } = await ctx.request.body(); 8 | let {jwt} = value 9 | 10 | if (!jwt) { 11 | jwt = ctx.request.headers.get('Authorization') 12 | } 13 | 14 | console.log('using: ', {jwt}) 15 | 16 | if (jwt) { 17 | // Validate JWT and if it is invalid delete from cookie 18 | const data: any = await validateJwt(jwt, Deno.env.get('JWT_KEY') || ''); 19 | 20 | if (!data.isValid || data.isExpired) { 21 | ctx.cookies.delete('jwt'); 22 | ctx.response.status = 401 23 | } else if (data) { 24 | // If it is valid select user and save in context state 25 | const user: any = users.find((u: User) => u.username === data.payload.iss); 26 | ctx.state.currentUser = user; 27 | console.log('found', {user}) 28 | await next(); 29 | } else { 30 | ctx.cookies.delete('jwt'); 31 | await next(); 32 | } 33 | } else { 34 | ctx.state.currentUser = null; 35 | await next(); 36 | } 37 | } 38 | 39 | export {userMiddleware}; -------------------------------------------------------------------------------- /api/users.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: String; 3 | password: String 4 | } 5 | 6 | export const users: User[] = [ 7 | { 8 | username: "midudev", 9 | password: "$2a$10$K8ZpdrjwzUWSTmtyM.SAHewu7Zxpq3kUXnv/DPZSM8k.DSrmSekxi" 10 | } 11 | ] -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | } 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "giffy", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/core": "^10.0.28", 7 | "@emotion/styled": "^10.0.27", 8 | "@testing-library/jest-dom": "^4.2.4", 9 | "@testing-library/react": "^9.3.2", 10 | "@testing-library/user-event": "^7.1.2", 11 | "emotion-theming": "^10.0.27", 12 | "formik": "^2.1.4", 13 | "intersection-observer": "0.10.0", 14 | "just-debounce-it": "1.1.0", 15 | "react": "16.13.1", 16 | "react-dom": "16.13.1", 17 | "react-helmet": "^6.0.0", 18 | "react-hook-form": "^5.7.2", 19 | "react-scripts": "3.4.1", 20 | "wouter": "2.4.0" 21 | }, 22 | "scripts": { 23 | "deploy": "vercel", 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@testing-library/react-hooks": "^3.3.0", 46 | "react-test-renderer": "^16.13.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/react-live-coding/f00db8f8a039be7d6aad404344706998ed62ee8b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/logo-old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/react-live-coding/f00db8f8a039be7d6aad404344706998ed62ee8b/public/logo-old.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/react-live-coding/f00db8f8a039be7d6aad404344706998ed62ee8b/public/logo.png -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/react-live-coding/f00db8f8a039be7d6aad404344706998ed62ee8b/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/react-live-coding/f00db8f8a039be7d6aad404344706998ed62ee8b/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | margin: 10px auto; 7 | padding: 20px 0; 8 | width: 144px; 9 | } 10 | 11 | .App-logo img { 12 | object-fit: cover; 13 | width: 100%; 14 | } 15 | 16 | .App-title { 17 | color: var(--theme-body-txt); 18 | margin-top: 0.6rem; 19 | } 20 | 21 | .App-content { 22 | background-color: var(--theme-body-bg); 23 | color: var(--theme-body-txt); 24 | display: flex; 25 | flex-direction: column; 26 | font-size: calc(10px + 2vmin); 27 | min-height: 100vh; 28 | padding: 16px; 29 | text-align: left; 30 | } 31 | 32 | /*----------*/ 33 | 34 | .App-main { 35 | display: flex; 36 | flex-direction: column; 37 | align-items: flex-start; 38 | } 39 | 40 | .App-wrapper { 41 | width: 100%; 42 | max-width: 90rem; 43 | margin: 0 auto; 44 | padding: 0 0.5em; 45 | } 46 | 47 | .App-results { 48 | flex-shrink: 2; 49 | width: 100%; 50 | } 51 | 52 | .App-category { 53 | margin-bottom: 25px; 54 | width: 100%; 55 | } 56 | 57 | @media screen and (min-width: 55rem){ 58 | .App-main{ 59 | flex-direction: initial; 60 | } 61 | 62 | .App-category{ 63 | position: sticky; 64 | top: var(--search-size-mx-h); 65 | width: 40%; 66 | max-width: 300px; 67 | } 68 | } 69 | 70 | .btn { 71 | border: 1px solid transparent; 72 | padding: .5rem 1rem; 73 | background-color: var(--brand-color_3); 74 | color: var(--theme-body-txt); 75 | cursor: pointer; 76 | font-size: 1rem; 77 | } 78 | 79 | .btn[disabled] { 80 | opacity: .3; 81 | pointer-events: none; 82 | } 83 | 84 | .btn:hover { 85 | background-color: var(--brand-color_6); 86 | } -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from "react"; 2 | import { Link, Route, Switch } from "wouter"; 3 | 4 | import Header from "components/Header"; 5 | 6 | import Register from 'pages/Register' 7 | import Login from "pages/Login"; 8 | import SearchResults from "pages/SearchResults"; 9 | import Detail from "pages/Detail"; 10 | import ErrorPage from "pages/ErrorPage"; 11 | 12 | import { UserContextProvider } from "context/UserContext"; 13 | import { GifsContextProvider } from "context/GifsContext"; 14 | 15 | import "./App.css"; 16 | 17 | const HomePage = React.lazy(() => import("./pages/Home")); 18 | 19 | export default function App() { 20 | return ( 21 | 22 |
23 | 24 |
25 |
26 | 27 |
28 | Giffy logo 29 |
30 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders without crashing', async () => { 6 | const { findByText } = render() 7 | const title = await findByText(/Última búsqueda/i) 8 | expect(title).toBeInTheDocument() 9 | }); 10 | -------------------------------------------------------------------------------- /src/__tests__/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, waitForElement, fireEvent, screen } from '@testing-library/react'; 3 | import App from '../App'; 4 | 5 | test('home work as expected', async () => { 6 | const {container} = render() 7 | const gifLink = await waitForElement( 8 | () => container.querySelector('.Gif-link') 9 | ) 10 | 11 | expect(gifLink).toBeVisible() 12 | }) 13 | 14 | test('search form could be used', async () => { 15 | render() 16 | const input = await screen.findByRole('textbox') 17 | const button = await screen.findByRole('button') 18 | 19 | fireEvent.change(input, { target: { value: 'Matrix' }}) 20 | fireEvent.click(button) 21 | 22 | const title = await screen.findByText('Matrix') 23 | expect(title).toBeVisible() 24 | }) 25 | -------------------------------------------------------------------------------- /src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Button, Link } from './styles' 3 | 4 | export default function ButtonComponent ({children, href, size = 'jkhdfgsjkfghsdsjkldfgdljk'}) { 5 | return href 6 | ? {children} 7 | : 8 | } -------------------------------------------------------------------------------- /src/components/Button/styles.js: -------------------------------------------------------------------------------- 1 | import { Link as LinkWouter } from "wouter"; 2 | import styled from '@emotion/styled' 3 | 4 | const SIZES = { 5 | small: '1rem', 6 | medium: '2rem', 7 | large: '3rem' 8 | } 9 | 10 | const getFontSize = props => { 11 | const size = SIZES[props.size] 12 | if (!size) { 13 | console.warn(`[Button Styled Component] This size is not correct. Use ${Object.keys(SIZES).join(', ')}`) 14 | return SIZES.small 15 | } 16 | return size 17 | } 18 | 19 | export const Link = styled(LinkWouter)` 20 | background-color: ${props => props.theme.colors.primary}; 21 | border: 1px solid transparent; 22 | color: ${({theme}) => theme.colors.textColor}; 23 | cursor: pointer; 24 | font-size: ${getFontSize}; 25 | padding: .5rem 1rem; 26 | 27 | :hover { 28 | background-color: var(--brand-color_2); 29 | } 30 | 31 | [disabled] { 32 | opacity: .3; 33 | pointer-events: none; 34 | } 35 | ` 36 | 37 | export const Button = Link.withComponent('button') -------------------------------------------------------------------------------- /src/components/Category/Category.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/components/Category/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "wouter"; 3 | import { 4 | CategoryTitle, 5 | CategoryListItem, 6 | CategoryLink, 7 | CategoryList, 8 | } from "./styles"; 9 | import "./Category.css"; 10 | 11 | export default function Category({ name, options = [] }) { 12 | return ( 13 |
14 | {name} 15 | 16 | {options.map((singleOption, index) => ( 17 | 22 | 23 | {singleOption} 24 | 25 | 26 | ))} 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Category/styles.js: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled' 2 | import { Link } from 'wouter' 3 | import { bps } from 'styles' 4 | 5 | export const CategoryTitle = styled.h3` 6 | color: var(--theme-body-txt); 7 | font-weight: bold; 8 | margin-bottom: 0.7rem; 9 | margin-top: 0.6rem; 10 | 11 | ${bps.greaterThanMobile} { 12 | text-align: right; 13 | } 14 | ` 15 | 16 | export const CategoryList = styled.ul` 17 | list-style-position: inside; 18 | display: flex; 19 | flex-wrap: wrap; 20 | padding: 0; 21 | margin: 0; 22 | 23 | ${bps.greaterThanMobile} { 24 | justify-content: flex-end; 25 | } 26 | ` 27 | 28 | const generateMultiColorCategory = props => { 29 | const NEED_WHITE_COLOR = [3, 4] 30 | const colorIndex = props.index % 5 + 1 31 | const needWhite = NEED_WHITE_COLOR.includes(colorIndex) 32 | const colorText = needWhite ? 'white' : 'var(--theme-body-bg)' 33 | 34 | return ` 35 | background-color: var(--brand-color_${colorIndex}); 36 | color: ${colorText};` 37 | } 38 | 39 | export const CategoryListItem = styled.li` 40 | list-style: none; 41 | padding: 0.3rem; 42 | margin: 0.2rem; 43 | font-size: 1.2rem; 44 | 45 | ${generateMultiColorCategory} 46 | ` 47 | 48 | export const CategoryLink = styled(Link)` 49 | color: inherit; 50 | font-size: 1em; 51 | text-decoration: none; 52 | font-size: 1em; 53 | transition: color ease-in 0.1s; 54 | ` -------------------------------------------------------------------------------- /src/components/Fav/Fav.css: -------------------------------------------------------------------------------- 1 | .gf-Fav { 2 | background: rgba(255, 172, 172, .3); 3 | border: 0; 4 | border-radius: 100px; 5 | cursor: pointer; 6 | width: 30px; 7 | height: 30px; 8 | transition: all .3s ease; 9 | } 10 | 11 | .gf-Fav:hover { 12 | background: rgba(255, 172, 172, .8); 13 | } 14 | 15 | .gf-Fav span { 16 | display: block; 17 | width: 20px; 18 | } -------------------------------------------------------------------------------- /src/components/Fav/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import useUser from "hooks/useUser"; 3 | import Modal from "components/Modal"; 4 | import Login from "components/Login"; 5 | import { useLocation } from "wouter"; 6 | 7 | import "./Fav.css"; 8 | 9 | export default function Fav({ id }) { 10 | const { isLogged, addFav, favs } = useUser(); 11 | const [, navigate] = useLocation(); 12 | const [showModal, setShowModal] = useState(false); 13 | 14 | const isFaved = favs.some((favId) => favId === id); 15 | 16 | const handleClick = () => { 17 | if (!isLogged) return setShowModal(true); 18 | addFav({ id }); 19 | }; 20 | 21 | const handleClose = () => { 22 | setShowModal(false); 23 | }; 24 | 25 | const handleLogin = () => { 26 | setShowModal(false); 27 | }; 28 | 29 | const [label, emoji] = isFaved 30 | ? ["Remove Gif from favorites", "❌"] 31 | : ["Add Gif to favorites", "❤️"]; 32 | 33 | return ( 34 | <> 35 | 40 | {showModal && ( 41 | 42 | 43 | 44 | )} 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Gif/Gif.css: -------------------------------------------------------------------------------- 1 | .Gif { 2 | --gifShadow-size : var(--gridList-gap, 6px); 3 | margin-bottom: .5em; 4 | position: relative; 5 | } 6 | 7 | .Gif-buttons { 8 | position: absolute; 9 | top: 8px; 10 | right: 8px; 11 | z-index: 1; 12 | } 13 | 14 | .Gif-link { 15 | color: #000; 16 | display: block; 17 | text-decoration: none; 18 | position: relative; 19 | width: 100%; 20 | } 21 | 22 | .Gif h4 { 23 | background: rgba(0, 0, 0, .3); 24 | bottom: 0; 25 | font-size: 12px; 26 | color: #fff; 27 | margin: 0; 28 | position: absolute; 29 | } 30 | 31 | .Gif img { 32 | width: 100%; 33 | object-fit: contain; 34 | vertical-align: top; 35 | } 36 | 37 | i { 38 | font-size: 80px; 39 | } 40 | 41 | .Gif { 42 | margin-bottom: 0; 43 | } 44 | 45 | .Gif, .Gif-link { 46 | height: 100%; 47 | } 48 | .Gif-link img { 49 | height: inherit; 50 | object-fit: cover; 51 | } 52 | 53 | .Gif:nth-child(5n + 1) img { 54 | background-color: var(--brand-color_1); 55 | } 56 | .Gif:nth-child(5n + 2) img { 57 | background-color: var(--brand-color_2); 58 | } 59 | .Gif:nth-child(5n + 3) img { 60 | background-color: var(--brand-color_3); 61 | } 62 | .Gif:nth-child(5n + 4) img { 63 | background-color: var(--brand-color_4); 64 | } 65 | .Gif:nth-child(5n + 5) img { 66 | background-color: var(--brand-color_5); 67 | } 68 | 69 | .Gif:hover:nth-child(5n + 1) { 70 | box-shadow: 0 0 0 var(--gifShadow-size) var(--brand-color_1); 71 | } 72 | .Gif:hover:nth-child(5n + 2) { 73 | box-shadow: 0 0 0 var(--gifShadow-size) var(--brand-color_2); 74 | } 75 | .Gif:hover:nth-child(5n + 3) { 76 | box-shadow: 0 0 0 var(--gifShadow-size) var(--brand-color_3); 77 | } 78 | .Gif:hover:nth-child(5n + 4) { 79 | box-shadow: 0 0 0 var(--gifShadow-size) var(--brand-color_4); 80 | } 81 | .Gif:hover:nth-child(5n + 5) { 82 | box-shadow: 0 0 0 var(--gifShadow-size) var(--brand-color_5); 83 | } 84 | 85 | .Gif:hover:nth-child(5n + 1) h4 { 86 | background-color: var(--brand-color_1); 87 | color: var(--theme-body-bg); 88 | } 89 | .Gif:hover:nth-child(5n + 2) h4 { 90 | background-color: var(--brand-color_2); 91 | color: var(--theme-body-bg); 92 | } 93 | .Gif:hover:nth-child(5n + 3) h4 { 94 | background-color: var(--brand-color_3); 95 | color: white; 96 | } 97 | .Gif:hover:nth-child(5n + 4) h4 { 98 | background-color: var(--brand-color_4); 99 | color: white; 100 | } 101 | .Gif:hover:nth-child(5n + 5) h4 { 102 | background-color: var(--brand-color_5); 103 | color: var(--theme-body-bg); 104 | } 105 | 106 | .Gif:nth-child(11n + 1) { 107 | grid-column: span 2; 108 | grid-row: span 2; 109 | } 110 | 111 | .Gif:nth-child(8n+1) { 112 | grid-column-end: span 2; 113 | grid-row-end: span 2; 114 | } 115 | 116 | .Gif:nth-child(10n + 3) { 117 | grid-column: span 2; 118 | grid-row: span 1; 119 | } 120 | 121 | @media screen and (max-width: 45rem) 122 | { 123 | .Gif:nth-child(11n + 1), 124 | .Gif:nth-child(8n+1), 125 | .Gif:nth-child(10n + 3) { 126 | grid-column: span 1; 127 | grid-row: span 1; 128 | } 129 | 130 | } -------------------------------------------------------------------------------- /src/components/Gif/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Link} from 'wouter' 3 | import Fav from 'components/Fav' 4 | import './Gif.css' 5 | 6 | export default function Gif ({ title, id, url }) { 7 | return ( 8 |
9 |
10 | 11 |
12 | 13 |

{title}

14 | {title} 15 | 16 |
17 | 18 | ) 19 | } -------------------------------------------------------------------------------- /src/components/Header/Header.css: -------------------------------------------------------------------------------- 1 | .gf-header { 2 | display: flex; 3 | justify-content: flex-end; 4 | } 5 | 6 | .gf-header a { 7 | color: #fff; 8 | font-size: 16px; 9 | font-weight: 600; 10 | } 11 | 12 | .gf-header a:hover { 13 | text-decoration: underline; 14 | } 15 | 16 | .gf-header a + a { 17 | margin: 0 12px; 18 | } -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {useRoute, Link} from 'wouter' 3 | 4 | import useUser from 'hooks/useUser' 5 | 6 | import './Header.css' 7 | 8 | export default function Header () { 9 | const {isLogged, logout} = useUser() 10 | const [match] = useRoute("/login"); 11 | 12 | const handleClick = e => { 13 | e.preventDefault() 14 | logout() 15 | } 16 | 17 | const renderLoginButtons = ({isLogged}) => { 18 | return isLogged 19 | ? 20 | Logout 21 | 22 | : <> 23 | 24 | Login 25 | 26 | 27 | Register 28 | 29 | 30 | } 31 | 32 | const content = match 33 | ? null 34 | : renderLoginButtons({isLogged}) 35 | 36 | return ( 37 |
38 | {content} 39 |
40 | ) 41 | } -------------------------------------------------------------------------------- /src/components/ListOfGifs/ListOfGifs.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --gridList-column: 250px; 3 | --gridList-rows: 250px; 4 | --gridList-gap: 6px; 5 | } 6 | 7 | .ListOfGifs { 8 | display: grid; 9 | min-height: 100vh; 10 | grid-template-columns: repeat(auto-fit, minmax(var(--gridList-column), 1fr)); 11 | grid-auto-rows: var(--gridList-rows); 12 | grid-template-rows: masonry; 13 | grid-auto-flow: row dense; 14 | grid-gap: var(--gridList-gap, 6px); 15 | gap: var(--gridList-gap, 6px); 16 | align-items: center; 17 | } 18 | 19 | @media screen and (min-width: 37.5rem) { 20 | :root { 21 | --gridList-column: 180px; 22 | --gridList-rows: 200px; 23 | } 24 | } 25 | 26 | @media screen and (min-width: 60rem) { 27 | :root { 28 | --gridList-column: 300px; 29 | --gridList-rows: 210px; 30 | } 31 | } -------------------------------------------------------------------------------- /src/components/ListOfGifs/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Gif from '../Gif' 3 | import './ListOfGifs.css' 4 | 5 | export default function ListOfGifs ({gifs}) { 6 | return
7 | { 8 | gifs.map(({id, title, url}) => 9 | 15 | ) 16 | } 17 |
18 | } -------------------------------------------------------------------------------- /src/components/Login/Login.css: -------------------------------------------------------------------------------- 1 | .form { 2 | max-width: 320px; 3 | width: 100%; 4 | } 5 | 6 | .form label { 7 | display: block; 8 | font-size: 14px; 9 | } 10 | 11 | .form-error, .form small { 12 | color: red; 13 | font-size: 12px; 14 | margin-top: -12px; 15 | display: block; 16 | } 17 | 18 | .form input { 19 | background: #fff; 20 | border: none; 21 | display: block; 22 | line-height: 2; 23 | font-size: 16px; 24 | width: 100%; 25 | padding: 0 8px; 26 | margin-bottom: 16px; 27 | margin-top: 4px; 28 | } 29 | 30 | .form input.error { 31 | border: 1px solid red; 32 | } 33 | 34 | .form .btn { 35 | width: 100%; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/components/Login/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import {useLocation} from "wouter" 3 | import useUser from 'hooks/useUser' 4 | import { useEffect } from "react"; 5 | import './Login.css' 6 | 7 | export default function Login({onLogin}) { 8 | const [username, setUsername] = useState(""); 9 | const [password, setPassword] = useState(""); 10 | const [, navigate] = useLocation() 11 | const {isLoginLoading, hasLoginError, login, isLogged} = useUser() 12 | 13 | useEffect(() => { 14 | if (isLogged) { 15 | navigate('/') 16 | onLogin && onLogin() 17 | } 18 | }, [isLogged, navigate, onLogin]) 19 | 20 | const handleSubmit = (e) => { 21 | e.preventDefault(); 22 | login({ username, password }) 23 | }; 24 | 25 | return ( 26 | <> 27 | {isLoginLoading && Checking credentials...} 28 | {!isLoginLoading && 29 |
30 | 38 | 39 | 48 | 49 | 50 |
51 | } 52 | { 53 | hasLoginError && Credentials are invalid 54 | } 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Modal/Modal.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | background-color: rgba(255, 255, 255, .8); 3 | backdrop-filter: blur(4px); 4 | position: fixed; 5 | bottom: 0; 6 | top: 0; 7 | left: 0; 8 | right: 0; 9 | z-index: 10000000; 10 | } 11 | 12 | .modal-content { 13 | background: #111; 14 | width: 300px; 15 | padding: 10px 20px; 16 | height: 80vh; 17 | margin: 10vh auto; 18 | position: relative; 19 | } 20 | 21 | .modal-content .btn { 22 | display: block; 23 | margin-bottom: 32px; 24 | } -------------------------------------------------------------------------------- /src/components/Modal/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import './Modal.css' 5 | 6 | function Modal ({ children, onClose }) { 7 | return
8 |
9 | 10 | {children} 11 |
12 |
13 | } 14 | 15 | export default function ModalPortal ({ children, onClose }) { 16 | return ReactDOM.createPortal( 17 | 18 | {children} 19 | , 20 | document.getElementById('root') 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/Register/index.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import registerService from "services/register"; 3 | import { useForm, ErrorMessage } from 'react-hook-form' 4 | 5 | export default function Register() { 6 | const {handleSubmit, register, errors} = useForm() 7 | 8 | const [registered, setRegistered] = useState(false) 9 | const [isSubmitting, setIsSubmitting] = useState(false) 10 | 11 | const onSubmit = values => { 12 | setIsSubmitting(true) 13 | registerService(values) 14 | .then(() => { 15 | setRegistered(true) 16 | setIsSubmitting(false) 17 | }) 18 | } 19 | 20 | if (registered) { 21 | return

22 | Congratulations ✅! You've been successfully registered! 23 |

24 | } 25 | 26 | return ( 27 | <> 28 |
29 | 35 | 36 | 37 | 44 | 45 | 46 | 49 | 50 | 51 | ); 52 | } -------------------------------------------------------------------------------- /src/components/Register/indexWithFormik.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react"; 2 | import register from "services/register"; 3 | import { Formik, Form, Field, ErrorMessage } from 'formik'; 4 | 5 | const validateFields = values => { 6 | const errors = {}; 7 | 8 | if (!values.username) { 9 | errors.username = "Required username"; 10 | } 11 | 12 | if (!values.password) { 13 | errors.password = "Required password"; 14 | } else if (values.password.length < 3) { 15 | errors.password = "Length must be greater than 3"; 16 | } 17 | 18 | return errors; 19 | } 20 | 21 | const initialValues = { 22 | username: "", 23 | password: "", 24 | } 25 | 26 | export default function Register() { 27 | const [registered, setRegistered] = useState(false) 28 | 29 | if (registered) { 30 | return

31 | Congratulations ✅! You've been successfully registered! 32 |

33 | } 34 | 35 | return ( 36 | <> 37 | { 41 | return register(values) 42 | .then(() => { 43 | setRegistered(true) 44 | }) 45 | .catch(() => { 46 | setFieldError("username", "This username is not valid"); 47 | }); 48 | }} 49 | > 50 | {({ errors, isSubmitting }) => ( 51 |
52 | 57 | 58 | 59 | 65 | 66 | 67 | 70 | 71 | )} 72 |
73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/components/SearchForm/SearchForm.module.css: -------------------------------------------------------------------------------- 1 | .c-search { 2 | display: flex; 3 | justify-content: center; 4 | padding-top: 0.5rem; 5 | padding-bottom: 0.5rem; 6 | background-color: var(--theme-body-bg); 7 | margin: 0 auto 30px; 8 | } 9 | 10 | .c-search-list { 11 | -moz-appearance: none; 12 | -webkit-appearance: none; 13 | -webkit-appearance: none; 14 | appearance: none; 15 | background-color: #fff; 16 | background: #fff; 17 | border: 0; 18 | border-left: 1px solid #ccc; 19 | border-radius: 0; 20 | line-height: 1.3; 21 | margin: 0; 22 | max-width: 100%; 23 | padding-left: 8px; 24 | background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E'); 25 | background-repeat: no-repeat, repeat; 26 | background-position: right .7em top 50%; 27 | background-size: .65em auto; 28 | } 29 | 30 | .c-search-input { 31 | font-size: 16px; 32 | border: none; 33 | padding: 4px 16px; 34 | } -------------------------------------------------------------------------------- /src/components/SearchForm/hook.js: -------------------------------------------------------------------------------- 1 | import { useReducer } from "react"; 2 | 3 | const ACTIONS = { 4 | CHANGE_KEYWORD: "change_keyword", 5 | CHANGE_RATING: "change_rating", 6 | }; 7 | 8 | const ACTIONS_REDUCERS = { 9 | [ACTIONS.CHANGE_KEYWORD]: (state, action) => ({ 10 | ...state, 11 | times: state.times + 1, 12 | keyword: action.payload, 13 | }), 14 | [ACTIONS.CHANGE_RATING]: (state, action) => ({ 15 | ...state, 16 | rating: action.payload, 17 | }), 18 | }; 19 | 20 | const reducer = (state, action) => { 21 | const actionReducer = ACTIONS_REDUCERS[action.type]; 22 | return actionReducer ? actionReducer(state, action) : state; 23 | }; 24 | 25 | export default function useForm({ 26 | initialKeyword = "", 27 | initialRating = "g", 28 | } = {}) { 29 | const [{ keyword, rating }, dispatch] = useReducer(reducer, { 30 | keyword: decodeURIComponent(initialKeyword), 31 | rating: initialRating 32 | }); 33 | 34 | return { 35 | changeKeyword: ({ keyword }) => 36 | dispatch({ type: ACTIONS.CHANGE_KEYWORD, payload: keyword }), 37 | changeRating: ({ rating }) => 38 | dispatch({ type: ACTIONS.CHANGE_RATING, payload: rating }), 39 | keyword, 40 | rating 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/SearchForm/hook.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react-hooks' 2 | import useForm from './hook' 3 | 4 | const setup = (params) => { 5 | const { result } = renderHook(() => useForm(params)) 6 | return result 7 | } 8 | 9 | test('should change the keyword', () => { 10 | const result = setup() 11 | 12 | act(() => { 13 | result.current.changeKeyword({ keyword: 'batman' }) 14 | }) 15 | 16 | expect(result.current.keyword).toBe('batman') 17 | }) 18 | 19 | test('should use the initial values', () => { 20 | const result = setup({ initialKeyword: 'avengers' }) 21 | 22 | expect(result.current.keyword).toBe('avengers') 23 | expect(result.current.rating).toBe('g') 24 | }) 25 | 26 | test('should count correctly how many times update keyword', () => { 27 | const result = setup() 28 | 29 | act(() => { 30 | result.current.changeKeyword({ keyword: 'b' }) 31 | result.current.changeKeyword({ keyword: 'ba' }) 32 | }) 33 | 34 | expect(result.current.keyword).toBe('ba') 35 | expect(result.current.times).toBe(2) 36 | }) -------------------------------------------------------------------------------- /src/components/SearchForm/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useLocation } from "wouter" 3 | import useForm from './hook' 4 | import css from "./SearchForm.module.css" 5 | import Button from 'components/Button' 6 | 7 | const RATINGS = ["g", "pg", "pg-13", "r"] 8 | 9 | export default function SearchForm({ 10 | initialKeyword = '', 11 | initialRating = RATINGS[0] 12 | }) { 13 | const [_, pushLocation] = useLocation() 14 | 15 | const {keyword, rating, changeKeyword, changeRating} = useForm({ initialKeyword, initialRating }) 16 | 17 | const onSubmit = ({ keyword }) => { 18 | if (keyword !== '') { 19 | // navegar a otra ruta 20 | pushLocation(`/search/${keyword}/${rating}`) 21 | } 22 | } 23 | 24 | const handleChange = (evt) => { 25 | changeKeyword({ keyword: evt.target.value }) 26 | } 27 | 28 | const handleSubmit = (evt) => { 29 | evt.preventDefault() 30 | onSubmit({ keyword }) 31 | } 32 | 33 | const handleChangeRating = (evt) => { 34 | changeRating({ rating: evt.target.value }) 35 | } 36 | 37 | return ( 38 | <> 39 |
40 | 41 | 48 | 56 |
57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Spinner/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './styles.css' 3 | 4 | export default function Spinner () { 5 | return
6 | } -------------------------------------------------------------------------------- /src/components/Spinner/styles.css: -------------------------------------------------------------------------------- 1 | .lds-ring { 2 | display: flex; 3 | align-items: center; 4 | height: 37px; 5 | padding-top: 15px; 6 | text-align: center; 7 | justify-content: center; 8 | margin: 0px auto; 9 | } 10 | 11 | .lds-ring div { 12 | display: inline-block; 13 | height: 10px; 14 | width: 10px; 15 | position: relative; 16 | box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 20px; 17 | margin: 37px 10px 10px; 18 | animation: 0.75s cubic-bezier(0.455, 0.03, 0.515, 0.955) 0s infinite 19 | alternate none running bouncer; 20 | } 21 | 22 | .lds-ring div:nth-child(5n + 1) { 23 | background-color: var(--brand-color_1); 24 | } 25 | .lds-ring div:nth-child(5n + 2) { 26 | animation-delay: calc(0.1s); 27 | background-color: var(--brand-color_2); 28 | } 29 | .lds-ring div:nth-child(5n + 3) { 30 | animation-delay: calc(0.2s); 31 | background-color: var(--brand-color_3); 32 | } 33 | .lds-ring div:nth-child(5n + 4) { 34 | animation-delay: calc(0.2s); 35 | background-color: var(--brand-color_4); 36 | } 37 | .lds-ring div:nth-child(5n + 5) { 38 | animation-delay: calc(0.4s); 39 | background-color: var(--brand-color_5); 40 | } 41 | 42 | @keyframes bouncer { 43 | 100% { 44 | transform: scale(1.75) translateY(-20px); 45 | } 46 | } -------------------------------------------------------------------------------- /src/components/TrendingSearches/TrendingSearches.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import getTrendingTerms from 'services/getTrendingTermsService' 3 | import Category from 'components/Category' 4 | 5 | export default function TrendingSearches () { 6 | const [trends, setTrends] = useState([]) 7 | 8 | useEffect(function () { 9 | const controller = new AbortController() 10 | getTrendingTerms({signal: controller.signal}) 11 | .then(setTrends) 12 | .catch(err => {}) 13 | 14 | return () => controller.abort() 15 | }, []) 16 | 17 | return 18 | } -------------------------------------------------------------------------------- /src/components/TrendingSearches/index.js: -------------------------------------------------------------------------------- 1 | import React, {Suspense} from 'react' 2 | import useNearScreen from 'hooks/useNearScreen' 3 | import Spinner from 'components/Spinner' 4 | 5 | const TrendingSearches = React.lazy( 6 | () => import('./TrendingSearches') 7 | ) 8 | 9 | export default function LazyTrending () { 10 | const {isNearScreen, fromRef} = useNearScreen({ 11 | distance: '0px' 12 | }) 13 | 14 | return
15 | }> 16 | {isNearScreen ? : } 17 | 18 |
19 | } -------------------------------------------------------------------------------- /src/context/GifsContext.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | 3 | const Context = React.createContext({}) 4 | 5 | export function GifsContextProvider ({children}) { 6 | const [gifs, setGifs] = useState([]) 7 | 8 | return 9 | {children} 10 | 11 | } 12 | 13 | export default Context -------------------------------------------------------------------------------- /src/context/StaticContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Context = React.createContext({ 4 | name: 'esto-es-sin-provider', 5 | suscribeteAlCanal: true 6 | }) 7 | 8 | export default Context -------------------------------------------------------------------------------- /src/context/UserContext.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import { useEffect } from 'react' 3 | import getFavs from 'services/getFavs' 4 | 5 | const Context = React.createContext({}) 6 | 7 | export function UserContextProvider ({children}) { 8 | const [favs, setFavs] = useState([]) 9 | const [jwt, setJWT] = useState( 10 | () => window.sessionStorage.getItem('jwt') 11 | ) 12 | 13 | useEffect(() => { 14 | if (!jwt) return setFavs([]) 15 | getFavs({jwt}).then(setFavs) 16 | }, [jwt]) 17 | 18 | return 24 | {children} 25 | 26 | } 27 | 28 | export default Context -------------------------------------------------------------------------------- /src/hooks/useGifs.js: -------------------------------------------------------------------------------- 1 | import {useContext, useEffect, useState} from 'react' 2 | import getGifs from '../services/getGifs' 3 | import GifsContext from '../context/GifsContext' 4 | 5 | const INITIAL_PAGE = 0 6 | 7 | export function useGifs ({ keyword, rating } = { keyword: null }) { 8 | const [loading, setLoading] = useState(false) 9 | const [loadingNextPage, setLoadingNextPage] = useState(false) 10 | 11 | const [page, setPage] = useState(INITIAL_PAGE) 12 | const {gifs, setGifs} = useContext(GifsContext) 13 | 14 | // recuperamos la keyword del localStorage 15 | const keywordToUse = keyword || localStorage.getItem('lastKeyword') || 'random' 16 | 17 | useEffect(function () { 18 | setLoading(true) 19 | 20 | getGifs({ keyword: keywordToUse, rating }) 21 | .then(gifs => { 22 | setGifs(gifs) 23 | setLoading(false) 24 | // guardamos la keyword en el localStorage 25 | localStorage.setItem('lastKeyword', keyword) 26 | }) 27 | }, [keyword, keywordToUse, rating, setGifs]) 28 | 29 | useEffect(function () { 30 | if (page === INITIAL_PAGE) return 31 | 32 | setLoadingNextPage(true) 33 | 34 | getGifs({ keyword: keywordToUse, page, rating }) 35 | .then(nextGifs => { 36 | setGifs(prevGifs => prevGifs.concat(nextGifs)) 37 | setLoadingNextPage(false) 38 | }) 39 | }, [keywordToUse, page, rating, setGifs]) 40 | 41 | return {loading, loadingNextPage, gifs, setPage} 42 | } -------------------------------------------------------------------------------- /src/hooks/useGlobalGifs.js: -------------------------------------------------------------------------------- 1 | import {useContext} from 'react' 2 | import GifsContext from '../context/GifsContext' 3 | 4 | export default function useGlobalGifs () { 5 | return useContext(GifsContext).gifs 6 | } -------------------------------------------------------------------------------- /src/hooks/useNearScreen.js: -------------------------------------------------------------------------------- 1 | import {useEffect, useState, useRef} from 'react' 2 | 3 | export default function useNearScreen ({ distance = '100px', externalRef, once = true } = {}) { 4 | const [isNearScreen, setShow] = useState(false) 5 | const fromRef = useRef() 6 | 7 | useEffect(() => { 8 | let observer 9 | 10 | const element = externalRef ? externalRef.current : fromRef.current 11 | 12 | const onChange = (entries, observer) => { 13 | const el = entries[0] 14 | if (el.isIntersecting) { 15 | setShow(true) 16 | once && observer.disconnect() 17 | } else { 18 | !once && setShow(false) 19 | } 20 | } 21 | 22 | Promise.resolve( 23 | typeof IntersectionObserver !== 'undefined' 24 | ? IntersectionObserver 25 | : import('intersection-observer') 26 | ).then(() => { 27 | observer = new IntersectionObserver(onChange, { 28 | rootMargin: distance 29 | }) 30 | 31 | if (element) observer.observe(element) 32 | }) 33 | 34 | return () => observer && observer.disconnect() 35 | }) 36 | 37 | return {isNearScreen, fromRef} 38 | } -------------------------------------------------------------------------------- /src/hooks/useSEO.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export default function useTitle ({ description, title }) { 4 | const prevTitle = useRef(document.title) 5 | const prevDescription = useRef(document.querySelector('meta[name="description"]').getAttribute('content')) 6 | 7 | useEffect(() => { 8 | const previousTitle = prevTitle.current 9 | if (title) { 10 | document.title = `${title} | Giffy` 11 | } 12 | 13 | return () => { 14 | console.log('efecto title') 15 | document.title = previousTitle 16 | } 17 | }, [title]) 18 | 19 | useEffect(() => { 20 | const metaDescription = document.querySelector('meta[name="description"]') 21 | const previousDescription = prevDescription.current 22 | 23 | if (description) { 24 | metaDescription.setAttribute('content', description) 25 | } 26 | 27 | return () => 28 | metaDescription.setAttribute('content', previousDescription) 29 | }, [description]) 30 | } -------------------------------------------------------------------------------- /src/hooks/useSingleGif.js: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from 'react' 2 | import {useGifs} from 'hooks/useGifs' 3 | import getSingleGif from 'services/getSingleGif' 4 | 5 | export default function useSingleGif ({ id }) { 6 | const {gifs} = useGifs() 7 | const gifFromCache = gifs.find(singleGif => singleGif.id === id) 8 | 9 | const [gif, setGif] = useState(gifFromCache) 10 | const [isLoading, setIsLoading] = useState(false) 11 | const [isError, setIsError] = useState(false) 12 | 13 | useEffect(function () { 14 | if (!gif) { 15 | setIsLoading(true) 16 | // llamar al servicio si no tenemos gif 17 | getSingleGif({ id }) 18 | .then(gif => { 19 | setGif(gif) 20 | setIsLoading(false) 21 | setIsError(false) 22 | }).catch(err => { 23 | setIsLoading(false) 24 | setIsError(true) 25 | }) 26 | } 27 | }, [gif, id]) 28 | 29 | return {gif, isLoading, isError} 30 | } -------------------------------------------------------------------------------- /src/hooks/useUser.js: -------------------------------------------------------------------------------- 1 | import {useCallback, useContext, useState} from 'react' 2 | import Context from 'context/UserContext' 3 | import loginService from 'services/login' 4 | import addFavService from 'services/addFav' 5 | 6 | export default function useUser () { 7 | const {favs, jwt, setFavs, setJWT} = useContext(Context) 8 | const [state, setState] = useState({ loading: false, error: false }) 9 | 10 | const login = useCallback(({username, password}) => { 11 | setState({loading: true, error: false }) 12 | loginService({username, password}) 13 | .then(jwt => { 14 | window.sessionStorage.setItem('jwt', jwt) 15 | setState({loading: false, error: false }) 16 | setJWT(jwt) 17 | }) 18 | .catch(err => { 19 | window.sessionStorage.removeItem('jwt') 20 | setState({loading: false, error: true }) 21 | console.error(err) 22 | }) 23 | }, [setJWT]) 24 | 25 | const addFav = useCallback(({id}) => { 26 | addFavService({id, jwt}) 27 | .then(setFavs) 28 | .catch(err => { 29 | console.error(err) 30 | }) 31 | }, [jwt, setFavs]) 32 | 33 | const logout = useCallback(() => { 34 | window.sessionStorage.removeItem('jwt') 35 | setJWT(null) 36 | }, [setJWT]) 37 | 38 | return { 39 | addFav, 40 | favs, 41 | isLogged: Boolean(jwt), 42 | isLoginLoading: state.loading, 43 | hasLoginError: state.error, 44 | login, 45 | logout 46 | } 47 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --fz: 16px; 3 | --theme--contrast-0: #121212; 4 | --theme-body-bg: #18061f; 5 | --theme-body-txt: #f3f3f3; 6 | --search-size-mx-h: 3rem; 7 | --zIndex-header: 20; 8 | --zIndex-component: 5; 9 | --brand-color_1: #00ff99; 10 | --brand-color_2: #00ccff; 11 | --brand-color_3: #9933ff; 12 | --brand-color_4: tomato; 13 | --brand-color_5: #fff35c; 14 | --brand-color_6: #8429de; 15 | } 16 | 17 | html { 18 | box-sizing: border-box; 19 | font-size: 1rem; 20 | } 21 | 22 | *, 23 | *:before, 24 | *:after { 25 | box-sizing: inherit; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | font-family: 'Manrope', sans-serif; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-size: var(--fz); 34 | background-color: var(--theme-body-bg); 35 | color: var(--theme-body-txt); 36 | } 37 | 38 | code { 39 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 40 | monospace; 41 | } 42 | 43 | 44 | a { 45 | color: #09f; 46 | font-size: 32px; 47 | text-decoration: none; 48 | } 49 | 50 | ul { 51 | list-style: none; 52 | margin: 0; 53 | padding: 0; 54 | } 55 | 56 | 57 | /* -------------------- rf-layout - Component / header */ 58 | .o-header { 59 | background-color: inherit; 60 | position: sticky; 61 | top: 0; 62 | z-index: var(--zIndex-header); 63 | max-height: var(--search-size-mx-h); 64 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | import { ThemeProvider } from 'emotion-theming' 7 | import { theme } from 'styles' 8 | 9 | ReactDOM.render( 10 | 11 | 12 | 13 | 14 | , 15 | document.getElementById('root') 16 | ); 17 | 18 | // If you want your app to work offline and load faster, you can change 19 | // unregister() to register() below. Note this comes with some pitfalls. 20 | // Learn more about service workers: https://bit.ly/CRA-PWA 21 | serviceWorker.unregister(); 22 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/Detail/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Redirect} from 'wouter' 3 | import Gif from 'components/Gif' 4 | import useSingleGif from 'hooks/useSingleGif' 5 | import Spinner from 'components/Spinner' 6 | import {Helmet} from 'react-helmet' 7 | 8 | export default function Detail ({ params }) { 9 | const {gif, isLoading, isError} = useSingleGif({id: params.id}) 10 | const title = gif ? gif.title : '' 11 | 12 | if (isLoading) { 13 | return ( 14 | <> 15 | 16 | Cargando... 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | if (isError) return 24 | if (!gif) return null 25 | 26 | return <> 27 | 28 | {title} || Giffy 29 | 30 |

{gif.title}

31 | 32 | 33 | } -------------------------------------------------------------------------------- /src/pages/ErrorPage/index.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @jsxFrag React.Fragment */ 3 | 4 | import React from "react"; 5 | import SearchForm from "components/SearchForm"; 6 | import { Helmet } from "react-helmet"; 7 | import Button from 'components/Button' 8 | import { css, jsx } from '@emotion/core' 9 | 10 | const gifsErrors = ['d2jjuAZzDSVLZ5kI', 'Bp3dFfoqpCKFyXuSzP', 'hv5AEBpH3ZyNoRnABG', 'hLwSzlKN8Fi6I']; 11 | 12 | const pageErrorStyles = css` 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | padding: 1rem; 18 | text-align: center; 19 | ` 20 | 21 | const codeErrorStyles = css` 22 | font-size: 5rem; 23 | font-weight: bold; 24 | font-style: italic; 25 | ` 26 | 27 | const msgErrorStyles = css` 28 | font-size: 1.5rem; 29 | margin: 1rem auto; 30 | ` 31 | 32 | const SIZE = '350px' 33 | 34 | const gifErrorStyles = css({ 35 | margin: "1rem auto", 36 | width: SIZE, 37 | height: SIZE, 38 | objectFit: 'cover' 39 | }) 40 | 41 | export default function ErrorPage() { 42 | const randomImage = () => { 43 | return `https://media.giphy.com/media/${gifsErrors[Math.floor(Math.random() * gifsErrors.length) + 1 ]}/giphy.gif` 44 | } 45 | 46 | return ( 47 | <> 48 | 49 | Error 404 | Giffy 50 | 51 |
52 | 53 |
54 |
55 |
56 | 404 57 | Sometimes gettings lost isn't that bad 58 | alt-page-404 59 | 60 |
61 |
62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/pages/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ListOfGifs from 'components/ListOfGifs' 3 | import {useGifs} from 'hooks/useGifs' 4 | import TrendingSearches from 'components/TrendingSearches' 5 | import SearchForm from 'components/SearchForm' 6 | import {Helmet} from 'react-helmet' 7 | 8 | export default function Home() { 9 | const {gifs} = useGifs() 10 | 11 | return ( 12 | <> 13 | 14 | Home | Giffy 15 | 16 |
17 | 18 |
19 |
20 |
21 |
22 |

Última búsqueda

23 | 24 |
25 |
26 | 27 |
28 |
29 |
30 | 31 | ) 32 | } -------------------------------------------------------------------------------- /src/pages/Login/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Login from 'components/Login' 3 | 4 | export default function LoginPage () { 5 | return <> 6 |

Login

7 | 8 | 9 | } -------------------------------------------------------------------------------- /src/pages/Register/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Register from 'components/Register' 3 | 4 | export default function RegisterPage () { 5 | return <> 6 |

Register

7 | 8 | 9 | } -------------------------------------------------------------------------------- /src/pages/SearchResults/index.js: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useRef, useEffect} from 'react' 2 | import Spinner from 'components/Spinner' 3 | import ListOfGifs from 'components/ListOfGifs' 4 | import SearchForm from 'components/SearchForm' 5 | 6 | import {useGifs} from 'hooks/useGifs' 7 | import useNearScreen from 'hooks/useNearScreen' 8 | 9 | import debounce from 'just-debounce-it' 10 | import {Helmet} from 'react-helmet' 11 | 12 | export default function SearchResults ({ params }) { 13 | const { keyword, rating } = params 14 | const { loading, gifs, setPage } = useGifs({ keyword, rating }) 15 | 16 | const externalRef = useRef() 17 | const {isNearScreen} = useNearScreen({ 18 | externalRef: loading ? null : externalRef, 19 | once: false 20 | }) 21 | 22 | const title = gifs ? `${gifs.length} resultados de ${keyword}` : '' 23 | 24 | const debounceHandleNextPage = useCallback(debounce( 25 | () => setPage(prevPage => prevPage + 1), 200 26 | ), [setPage]) 27 | 28 | useEffect(function () { 29 | if (isNearScreen) debounceHandleNextPage() 30 | }, [debounceHandleNextPage, isNearScreen]) 31 | 32 | return <> 33 | {loading 34 | ? 35 | : <> 36 | 37 | {title} 38 | 39 | 40 | 41 |
42 | 43 |
44 |
45 |

46 | {decodeURI(keyword)} 47 |

48 | 49 |
50 |
51 | 52 | } 53 | 54 | } -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/services/addFav.js: -------------------------------------------------------------------------------- 1 | const ENDPOINT = 'https://deno-api-users-login.herokuapp.com' 2 | 3 | export default function addFav ({ id, jwt }) { 4 | return fetch(`${ENDPOINT}/favs/${id}`, { 5 | method: 'POST', 6 | headers: { 7 | "Content-Type": "application/json" 8 | }, 9 | body: JSON.stringify({jwt}) 10 | }).then(res => { 11 | if (!res.ok) throw new Error('Response is NOT ok') 12 | return res.json() 13 | }).then(res => { 14 | const { favs } = res 15 | return favs 16 | }) 17 | } -------------------------------------------------------------------------------- /src/services/getFavs.js: -------------------------------------------------------------------------------- 1 | const ENDPOINT = 'https://deno-api-users-login.herokuapp.com' 2 | 3 | export default function getFavs ({ jwt }) { 4 | return fetch(`${ENDPOINT}/favs`, { 5 | method: 'GET', 6 | headers: { 7 | "Authorization": jwt, 8 | "Content-Type": "application/json" 9 | } 10 | }).then(res => { 11 | if (!res.ok) throw new Error('Response is NOT ok') 12 | return res.json() 13 | }).then(res => { 14 | const { favs } = res 15 | return favs 16 | }) 17 | } -------------------------------------------------------------------------------- /src/services/getGifs.js: -------------------------------------------------------------------------------- 1 | import {API_KEY, API_URL} from './settings' 2 | 3 | const fromApiResponseToGifs = apiResponse => { 4 | const {data = []} = apiResponse 5 | if (Array.isArray(data)) { 6 | const gifs = data.map(image => { 7 | const {images, title, id} = image 8 | const { url } = images.downsized_medium 9 | return { title, id, url } 10 | }) 11 | return gifs 12 | } 13 | return [] 14 | } 15 | 16 | export default function getGifs({ 17 | limit = 15, 18 | rating = "g", 19 | keyword = "morty", 20 | page = 0, 21 | } = {}) { 22 | const apiURL = `${API_URL}/gifs/search?api_key=${API_KEY}&q=${keyword}&limit=${limit}&offset=${ 23 | page * limit 24 | }&rating=${rating}&lang=en` 25 | 26 | return fetch(apiURL) 27 | .then((res) => res.json()) 28 | .then(fromApiResponseToGifs) 29 | } -------------------------------------------------------------------------------- /src/services/getSingleGif.js: -------------------------------------------------------------------------------- 1 | import {API_KEY, API_URL} from './settings' 2 | 3 | const fromApiResponseToGifs = apiResponse => { 4 | const {data} = apiResponse 5 | const {images, title, id} = data 6 | const { url } = images.downsized_medium 7 | return { title, id, url } 8 | } 9 | 10 | export default function getSingleGif ({ id }) { 11 | return fetch(`${API_URL}/gifs/${id}?api_key=${API_KEY}`) 12 | .then(res => res.json()) 13 | .then(fromApiResponseToGifs) 14 | } -------------------------------------------------------------------------------- /src/services/getTrendingTermsService.js: -------------------------------------------------------------------------------- 1 | import {API_KEY, API_URL} from './settings' 2 | 3 | const fromApiResponseToGifs = apiResponse => { 4 | const {data = []} = apiResponse 5 | return data 6 | } 7 | 8 | export default function getTrendingTerms ({ signal }) { 9 | const apiURL = `${API_URL}/trending/searches?api_key=${API_KEY}` 10 | 11 | return fetch(apiURL, {signal}) 12 | .then(res => res.json()) 13 | .then(fromApiResponseToGifs) 14 | } -------------------------------------------------------------------------------- /src/services/login.js: -------------------------------------------------------------------------------- 1 | const ENDPOINT = 'https://deno-api-users-login.herokuapp.com' 2 | 3 | export default function login ({ username, password }) { 4 | return fetch(`${ENDPOINT}/login`, { 5 | method: 'POST', 6 | headers: { 7 | "Content-Type": "application/json" 8 | }, 9 | body: JSON.stringify({username, password}) 10 | }).then(res => { 11 | if (!res.ok) throw new Error('Response is NOT ok') 12 | return res.json() 13 | }).then(res => { 14 | const { jwt } = res 15 | return jwt 16 | }) 17 | } -------------------------------------------------------------------------------- /src/services/register.js: -------------------------------------------------------------------------------- 1 | const ENDPOINT = 'https://deno-api-users-login.herokuapp.com' 2 | 3 | export default function register ({ username, password }) { 4 | return fetch(`${ENDPOINT}/register`, { 5 | method: 'POST', 6 | headers: { 7 | "Content-Type": "application/json" 8 | }, 9 | body: JSON.stringify({username, password}) 10 | }).then(res => { 11 | if (!res.ok) throw new Error('Response is NOT ok') 12 | return true 13 | }) 14 | } -------------------------------------------------------------------------------- /src/services/settings.js: -------------------------------------------------------------------------------- 1 | export const API_KEY = 'dIJrma20pSU6ymMwWnDbiaT7NFHeAGVa' 2 | 3 | export const API_URL = 'https://api.giphy.com/v1' -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/styles/index.js: -------------------------------------------------------------------------------- 1 | export const bps = { 2 | greaterThanMobile: '@media screen and (min-width: 55rem)' 3 | } 4 | 5 | export const theme = { 6 | colors: { 7 | textColor: 'var(--theme-body-txt)', 8 | primary: 'var(--brand-color_3)' 9 | }, 10 | fontSizes: { 11 | 12 | }, 13 | paddings: { 14 | xs: '4px', 15 | s: '8px', 16 | m: '16px', 17 | l: '32px' 18 | } 19 | } --------------------------------------------------------------------------------