├── .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 | [](https://youtu.be/T_j60n1zgu0)
5 |
6 | ### [2. Crea un app con create-react-app](https://youtu.be/QBLbXgeXMU8)
7 | [](https://youtu.be/QBLbXgeXMU8)
8 |
9 | ### [3. Custom Hooks y React Context](https://youtu.be/2qgs7buSnHQ)
10 | [](https://youtu.be/2qgs7buSnHQ)
11 |
12 | ### [4. Lazy Load, Suspense y Paginación](https://youtu.be/VcxXipZg1-0)
13 | [](https://youtu.be/VcxXipZg1-0)
14 |
15 | ### [5. CSS Grid, Infinite Scroll y Testing](https://youtu.be/oCHdFiCgOSE)
16 | [](https://youtu.be/oCHdFiCgOSE)
17 |
18 | ### [6. React.memo y Deploy con Vercel](https://youtu.be/Wo7_OVtu1ls)
19 | [](https://youtu.be/Wo7_OVtu1ls)
20 |
21 | ### [7. SEO con React y Deploy integrado con GitHub](https://youtu.be/b-pwpHaYOTI)
22 | [](https://youtu.be/b-pwpHaYOTI)
23 |
24 | ### [8. useReducer y testing de Hooks](https://youtu.be/Wjy_nlYXTik)
25 | [](https://youtu.be/Wjy_nlYXTik)
26 |
27 | ### [9. Login y Sesión de Usuarios para guardar Favoritos en React](https://youtu.be/VT5S9Y49SYs)
28 | [](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 | [](https://youtu.be/dtbI6gDnTFU)
32 |
33 | ### [11. CSS en JS y Styled Components con Emotion](https://www.youtube.com/watch?v=DjVGdUM1dHQ)
34 | [](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 | [](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 |
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 |

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 |
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 |
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 |
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 |
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 |
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 |
54 |
55 |
56 |
404
57 |
Sometimes gettings lost isn't that bad
58 |
})
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 |
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 |
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 | }
--------------------------------------------------------------------------------