├── .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 |
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 |
--------------------------------------------------------------------------------