├── .env.example ├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.tsx ├── components │ └── loading │ │ └── Loading.tsx ├── index.tsx ├── pages │ └── ImageGallery.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts ├── services │ └── api.ts ├── setupTests.ts ├── styles │ └── tailwind.css └── types │ └── index.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_UNSPLASH_BASE_URL=https://api.unsplash.com/ 2 | REACT_APP_UNSPLASH_API_KEY=6DKZnOT5zNq8oodEBWwwtn6KS2g1eqjWlN0aKu0ULLQ -------------------------------------------------------------------------------- /.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 | .env 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Image Gallery with Infinite Scroll 2 | 3 | The frontend challenge to build Image Gallery with Infinite Scroll using React. 4 | 5 | ## Tech stack 6 | 7 | - React 8 | - TypeScript 9 | - TailwindCSS 10 | 11 | ## How to Install 12 | - Install node_modules \ 13 | `yarn install` 14 | - Make a copy of `.env.example` as `.env` \ 15 | Set `REACT_APP_UNSPLASH_API_KEY` as Unsplash API key 16 | 17 | ## How to Run 18 | 19 | - `yarn start` to start the app 20 | 21 | ## Commands for devlopment 22 | 23 | - `yarn test` to test the app 24 | - `yarn run build` to build a production bundle 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gallery-infinite-scroll", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.14.1", 7 | "@testing-library/react": "^13.0.0", 8 | "@testing-library/user-event": "^13.2.1", 9 | "@types/jest": "^27.0.1", 10 | "@types/lodash": "^4.14.182", 11 | "@types/node": "^16.7.13", 12 | "@types/react": "^18.0.0", 13 | "@types/react-alert": "^7.0.2", 14 | "@types/react-dom": "^18.0.0", 15 | "@types/react-toastify": "^4.1.0", 16 | "axios": "^0.27.2", 17 | "lodash": "^4.17.21", 18 | "react": "^18.2.0", 19 | "react-alert": "^7.0.3", 20 | "react-alert-template-basic": "^1.0.2", 21 | "react-dom": "^18.2.0", 22 | "react-photo-gallery": "^8.0.0", 23 | "react-scripts": "5.0.1", 24 | "react-simple-image-viewer": "^1.2.2", 25 | "react-toastify": "^9.0.7", 26 | "tailwindcss": "^3.1.6", 27 | "typescript": "^4.4.2", 28 | "web-vitals": "^2.1.0" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seven-33/react-image-infinite-scroll/72a7d948c4e1222245a5b3477e7160595ef3c83f/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seven-33/react-image-infinite-scroll/72a7d948c4e1222245a5b3477e7160595ef3c83f/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Seven-33/react-image-infinite-scroll/72a7d948c4e1222245a5b3477e7160595ef3c83f/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 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import ImageGallery from "./pages/ImageGallery"; 2 | 3 | const App = () => { 4 | return ( 5 | <> 6 | 7 | 8 | ); 9 | }; 10 | 11 | export default App; 12 | -------------------------------------------------------------------------------- /src/components/loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | const Loading = () => { 2 | return ( 3 |
4 |
5 | 11 | 15 | 19 | 20 | Loading... 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default Loading; 27 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import reportWebVitals from "./reportWebVitals"; 5 | 6 | import "./styles/tailwind.css"; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById("root") as HTMLElement 10 | ); 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /src/pages/ImageGallery.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useMemo } from "react"; 2 | import Gallery, { PhotoClickHandler } from "react-photo-gallery"; 3 | import ImageViewer from "react-simple-image-viewer"; 4 | import { ToastContainer, toast } from "react-toastify"; 5 | import "react-toastify/dist/ReactToastify.css"; 6 | import _ from "lodash"; 7 | 8 | import { fetchPhotos } from "../services/api"; 9 | 10 | import { Image, Photo } from "../types"; 11 | 12 | import Loading from "../components/loading/Loading"; 13 | 14 | const App = () => { 15 | const [images, setImageObjects] = useState([]); 16 | const [isFirstLoading, setIsFirstLoading] = useState(true); 17 | const [isSpinner, setIsSpinner] = useState(true); 18 | const [pageNumber, setPageNumber] = useState(0); 19 | const [isViewerOpen, setIsViewerOpen] = useState(false); 20 | const [currentImage, setCurrentImage] = useState(0); 21 | 22 | const notify = () => 23 | toast("Something went wrong, while fetching the photos from Unsaplsh!"); 24 | 25 | const imageUrls = useMemo(() => images.map((image) => image.src), [images]); 26 | 27 | const fetchImages = useCallback( 28 | _.debounce((page) => { 29 | setIsSpinner(true); 30 | fetchPhotos(page) 31 | .then((response) => { 32 | const responseImages: Photo[] = response.data; 33 | const images = responseImages.map((image) => ({ 34 | src: image.urls.regular, 35 | width: image.width, 36 | height: image.height, 37 | })); 38 | setImageObjects((imageObjects) => [...imageObjects, ...images]); 39 | setIsFirstLoading(false); 40 | setIsSpinner(false); 41 | }) 42 | .catch((e) => { 43 | notify(); 44 | }); 45 | }, 500), 46 | [images] 47 | ); 48 | console.log(isSpinner); 49 | 50 | const handleScroll = useCallback(() => { 51 | if ( 52 | Math.ceil(window.innerHeight + window.scrollY) >= 53 | document.documentElement.offsetHeight 54 | ) { 55 | setPageNumber((prevPage) => prevPage + 1); 56 | console.log(pageNumber); 57 | } 58 | }, []); 59 | 60 | const handleOpenLightbox: PhotoClickHandler = (event, { photo, index }) => { 61 | setCurrentImage(index); 62 | setIsViewerOpen(true); 63 | }; 64 | 65 | const handleCloseLightbox = () => { 66 | setCurrentImage(0); 67 | setIsViewerOpen(false); 68 | }; 69 | 70 | useEffect(() => { 71 | fetchImages(pageNumber); 72 | }, [pageNumber]); 73 | 74 | useEffect(() => { 75 | window.addEventListener("scroll", handleScroll); 76 | 77 | return () => window.removeEventListener("scroll", handleScroll); 78 | }, []); 79 | 80 | return ( 81 |
82 | {isFirstLoading ? ( 83 |
84 | 85 |
86 | ) : ( 87 | 88 | )} 89 | 90 | {isViewerOpen && ( 91 | 101 | )} 102 | 103 | {!isFirstLoading && isSpinner && } 104 | 105 |
106 | ); 107 | }; 108 | 109 | export default App; 110 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from 'axios' 2 | 3 | const unsplashApiKey = process.env.REACT_APP_UNSPLASH_API_KEY 4 | const baseURL = process.env.REACT_APP_UNSPLASH_BASE_URL 5 | 6 | const createAxiosInstance = (baseURL: string) => { 7 | const instance: AxiosInstance = axios.create({ 8 | baseURL, 9 | headers: { Authorization: `Client-ID ${unsplashApiKey}` }, 10 | }) 11 | 12 | return instance 13 | } 14 | 15 | const API = createAxiosInstance(baseURL ?? '') 16 | 17 | const fetchPhotos = (pageNumber: number) => 18 | API.get('/photos/?per_page=30&page=' + pageNumber) 19 | 20 | export { fetchPhotos } 21 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 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'; 6 | -------------------------------------------------------------------------------- /src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type Image = { 2 | src: string 3 | srcSet?: string | string[] | undefined 4 | sizes?: string | string[] | undefined 5 | width: number 6 | height: number 7 | alt?: string | undefined 8 | key?: string | undefined 9 | } 10 | 11 | export type Photo = { 12 | id: number 13 | width: number 14 | height: number 15 | urls: { large: string; regular: string; raw: string; small: string } 16 | color: string | null 17 | user: { 18 | username: string 19 | name: string 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "target": "esnext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules"] 22 | } 23 | --------------------------------------------------------------------------------