├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── images.d.ts ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── server ├── build │ ├── routes │ │ ├── outline.js │ │ └── search.js │ └── server.js ├── routes │ ├── outline.ts │ └── search.ts ├── server.ts ├── tsconfig.json └── tslint.json ├── src ├── App.css ├── App.tsx ├── actions │ └── index.ts ├── components │ ├── About.tsx │ ├── Loader.tsx │ ├── Outline.tsx │ ├── Result.tsx │ ├── ResultsList.tsx │ ├── ResultsView.tsx │ └── SearchBox.tsx ├── containers │ └── Home.tsx ├── images │ ├── back-button.svg │ ├── document.svg │ ├── glass.svg │ └── imgLoader.gif ├── index.css ├── index.tsx ├── logo.svg ├── reducers │ ├── counterReducer.ts │ ├── index.ts │ ├── loadingStatus.ts │ ├── outlineReducer.ts │ ├── queryReducer.ts │ ├── resultsReducer.ts │ └── screenshotsReducer.ts ├── registerServiceWorker.ts ├── reset.min.css └── style │ ├── About.css │ ├── Loader.css │ ├── Outline.css │ ├── Result.css │ ├── ResultsView.css │ └── SearchBox.css ├── test └── index.js ├── tsconfig.json ├── tsconfig.prod.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test 3 | .vscode 4 | .idea 5 | .git 6 | .gitignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | #editors 7 | .vscode 8 | .idea 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | install: 5 | - yarn global add mocha && yarn add chai puppeteer 6 | script: 7 | - mocha -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.1.0 2 | COPY . . 3 | RUN yarn install --production && yarn build 4 | WORKDIR ./server 5 | EXPOSE 1337 6 | CMD node ./build/server.js -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 JoshuaRabiu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spresso-search 2 | [![Build Status](https://travis-ci.com/JoshuaRabiu/spresso-search.svg?branch=master)](https://travis-ci.com/JoshuaRabiu/spresso-search) 3 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) 4 | 5 | ![Tesla Model 3 Search Results](https://i.imgur.com/W4DRwiS.png) 6 | 7 | >Visual metasearch engine built with React, Redux, Express, and TypeScript. 8 | 9 | [Live link to site](http://spresso-search.herokuapp.com/) 10 | 11 | ## About 12 | Spresso Search scrapes search results from Google using the [node x-ray](https://github.com/matthewmueller/x-ray) library, and uses the same library to scrape obtain meta-information on webpages (preview images, favicons). There is a screenshot feature, which takes screenshots of sites that don't have meta preview images in their HTML. There is also a text-outline feature, powered by [node-unfluff](https://github.com/ageitgey/node-unfluff), which scrapes text content from web pages(ideal for articles & other text-rich pages), allowing the user to read the contents of a web page in clean, formatted text and without leaving the Spresso Search site. 13 | 14 | ## Running Locally 15 | To run Spresso Search locally, first clone the repo with: `git clone https://github.com/JoshuaRabiu/spresso-search.git` 16 | 17 | 18 | Then `cd` into its directory: `cd spresso-search` 19 | 20 | Install the dependencies with `yarn install` 21 | 22 | Then run `yarn start` to run the client side code. The app should be visible on port 3000. 23 | 24 | Open a new terminal tab/window in the same directory, and run `cd server` to go into the server directory. 25 | 26 | Run `node ./build/server.js` to start the server. The app is now ready for use. 27 | 28 | If making any modifications to the server's TypeScript code, you should start the TypeScript compiler in watch mode with 29 | 30 | `tsc -w` so your changes can be tracked in the JS build. 31 | 32 | 33 | ## License 34 | [MIT](https://github.com/JoshuaRabiu/spresso-search/blob/master/LICENSE.md) 35 | -------------------------------------------------------------------------------- /images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.png' 3 | declare module '*.jpg' 4 | declare module '*.jpeg' 5 | declare module '*.gif' 6 | declare module '*.bmp' 7 | declare module '*.tiff' 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spresso-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/express": "^4.16.0", 7 | "@types/rc-tooltip": "^3.4.12", 8 | "@types/react-infinite-scroll-component": "^4.2.3", 9 | "@types/react-redux": "^6.0.9", 10 | "@types/react-router-dom": "^4.3.1", 11 | "@types/redux-logger": "^3.0.6", 12 | "axios": "^0.18.0", 13 | "body-parser": "^1.18.3", 14 | "chai": "^4.2.0", 15 | "compression": "^1.7.3", 16 | "express": "^4.16.3", 17 | "git": "^0.1.5", 18 | "morgan": "^1.9.0", 19 | "puppeteer": "^1.9.0", 20 | "rc-tooltip": "^3.7.3", 21 | "react": "^16.4.2", 22 | "react-dom": "^16.4.2", 23 | "react-infinite-scroll-component": "^4.2.0", 24 | "react-redux": "^5.0.7", 25 | "react-router-dom": "^4.3.1", 26 | "react-scripts-ts": "3.1.0", 27 | "redux": "^4.0.1", 28 | "redux-thunk": "^2.3.0", 29 | "request": "^2.88.0", 30 | "styled-components": "^3.4.5", 31 | "unfluff": "^3.2.0" 32 | }, 33 | "scripts": { 34 | "start": "react-scripts-ts start", 35 | "build": "react-scripts-ts build", 36 | "test": "react-scripts-ts test --env=jsdom", 37 | "eject": "react-scripts-ts eject" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^23.3.5", 41 | "@types/node": "^10.11.7", 42 | "@types/react": "^16.4.16", 43 | "@types/react-dom": "^16.0.9", 44 | "typescript": "^3.1.3" 45 | }, 46 | "proxy": "http://127.0.0.1:1337" 47 | } 48 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshuaRabiu/spresso-search/dc2fee6c3f4b53f766f8dfb36638383df3617f9d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /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 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /server/build/routes/outline.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const { Request, Response, Router } = require('express'); 4 | const request = require('request'); 5 | const unfluff = require('unfluff'); 6 | const router = Router(); 7 | router.post('/:site(*)', (req, response) => { 8 | const site = req.params.site; 9 | request(site, (err, res, body) => { 10 | const data = unfluff(body); 11 | response.send({ 12 | title: data.title, 13 | text: data.text 14 | }); 15 | }); 16 | }); 17 | exports.OutlineController = router; 18 | -------------------------------------------------------------------------------- /server/build/routes/search.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const { Router } = require('express'); 4 | const path = require('path'); 5 | const { get } = require('axios'); 6 | const router = Router(); 7 | router.get('*', (req, res, next) => { 8 | res.sendFile(path.resolve('../', 'build/index.html')); 9 | }); 10 | router.post('/:query/:start?', async (req, res) => { 11 | const { query, start } = req.params; 12 | try { 13 | const { data: { items } } = await get(` 14 | https://www.googleapis.com/customsearch/v1?key=AIzaSyBHU6yrWSnBlxoQXreinayGeLuWlyTyaLI&cx=000950167190857528722:vf0rypkbf0w&start=${start 15 | ? start 16 | : 10}&q=${encodeURI(query)}`); 17 | res.send(items); 18 | } 19 | catch (error) { 20 | console.error(error); 21 | } 22 | }); 23 | exports.SearchController = router; 24 | -------------------------------------------------------------------------------- /server/build/server.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const express = require('express'); 3 | const bodyParser = require('body-parser'); 4 | const logger = require('morgan'); 5 | const { SearchController } = require('./routes/search.js'); 6 | const { OutlineController } = require('./routes/outline.js'); 7 | const path = require('path'); 8 | const compression = require('compression'); 9 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 10 | const app = express(); 11 | app.use(compression()); 12 | app.use(express.static(path.resolve('../', 'build'))); 13 | app.use(bodyParser.text()); 14 | app.use(logger('dev')); 15 | app.use('/search', SearchController); 16 | app.use('/outline', OutlineController); 17 | const port = process.env.PORT || 1337; 18 | app.listen(port, () => { 19 | console.log(`App listening on port ${port}`); 20 | }); 21 | -------------------------------------------------------------------------------- /server/routes/outline.ts: -------------------------------------------------------------------------------- 1 | const { Request, Response, Router } = require('express'); 2 | import * as express from 'express'; 3 | const request = require('request'); 4 | const unfluff = require('unfluff'); 5 | 6 | const router: any = Router(); 7 | 8 | router.post('/:site(*)', (req: express.Request, response: express.Response) => { 9 | const site = req.params.site; 10 | request(site, (err: Error, res: any, body: any) => { 11 | const data = unfluff(body); 12 | response.send({ 13 | title: data.title, 14 | text: data.text 15 | }); 16 | }); 17 | }); 18 | 19 | export const OutlineController = router; -------------------------------------------------------------------------------- /server/routes/search.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | const { Router } = require('express'); 3 | const path = require('path'); 4 | const { get } = require('axios'); 5 | 6 | const router: express.Router = Router(); 7 | 8 | router.get('*', (req: express.Request, res: express.Response, next: express.NextFunction): void => { 9 | res.sendFile(path.resolve('../', 'build/index.html')); 10 | }); 11 | 12 | router.post('/:query/:start?', async (req: express.Request, res: express.Response) => { 13 | const { query, start } = req.params; 14 | try { 15 | const { data: { items } } = await get(` 16 | https://www.googleapis.com/customsearch/v1?key=AIzaSyBHU6yrWSnBlxoQXreinayGeLuWlyTyaLI&cx=000950167190857528722:vf0rypkbf0w&start=${start 17 | ? start 18 | : 10}&q=${encodeURI(query)}`); 19 | 20 | res.send(items); 21 | } catch (error) { 22 | console.error(error); 23 | } 24 | }); 25 | 26 | export const SearchController = router; 27 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const logger = require('morgan'); 4 | const { SearchController } = require('./routes/search.js'); 5 | const { OutlineController } = require('./routes/outline.js'); 6 | const path = require('path'); 7 | const compression = require('compression'); 8 | 9 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 10 | 11 | const app = express(); 12 | app.use(compression()); 13 | app.use(express.static(path.resolve('../', 'build'))); 14 | app.use(bodyParser.text()); 15 | app.use(logger('dev')); 16 | app.use('/search', SearchController); 17 | app.use('/outline', OutlineController); 18 | 19 | const port = process.env.PORT || 1337; 20 | 21 | app.listen(port, () => { 22 | console.log(`App listening on port ${port}`); 23 | }); 24 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ESNEXT", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./build", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 34 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 35 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | } 59 | } -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "quotemark": [true, "single", "jsx-double"] 9 | }, 10 | "rulesDirectory": [] 11 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .about{ 2 | float: right; 3 | padding: 15px; 4 | } 5 | .about:hover{ 6 | text-decoration: underline; 7 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Home from './containers/Home'; 3 | import { About } from './components/About'; 4 | import { Switch, Route } from 'react-router-dom'; 5 | import './App.css' 6 | 7 | export const App: React.StatelessComponent = ():JSX.Element => ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ) 15 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { store } from '../reducers'; 3 | 4 | export const setQuery = (e: any): void => { 5 | store.dispatch({ type: 'SET_QUERY', payload: encodeURI(e.target.value) }); 6 | }; 7 | 8 | export const handleKey = (e: any, reset?: string): void => { 9 | if (e.key === 'Enter') { 10 | if (reset) { 11 | store.dispatch({ type: 'RESET_RESULTS' }); 12 | } 13 | search(); 14 | } 15 | }; 16 | 17 | export const search = (reset?: any): void => { 18 | store.dispatch((dispatch: any): any => { 19 | if (reset) { 20 | dispatch({ type: 'RESET_RESULTS' }); 21 | } 22 | dispatch({ type: 'LOADING_STATUS', payload: true }); 23 | axios 24 | .post(`/search/${store.getState().query}`) 25 | .then((res: any) => dispatch({ type: 'SEND_RESULTS', payload: res.data })) 26 | .then(() => { 27 | dispatch({ type: 'LOADING_STATUS', payload: false }); 28 | screenGrab(); 29 | }); 30 | }); 31 | }; 32 | 33 | const screenGrab = (): void => { 34 | const arr = []; 35 | const results: any = store.getState().results; 36 | for (let i = 0; i < results.length; i++) { 37 | if (!!results[i].image === false) { 38 | arr.push(results[i].link); 39 | } 40 | } 41 | getScreenshot(arr); 42 | }; 43 | 44 | const getScreenshot = (links: string[]): void => { 45 | const len = links.length; 46 | for (let i = 0; i < len; i++) { 47 | const link = links[i]; 48 | const formattedLink = encodeURIComponent(link); 49 | axios 50 | .get(`https://www.googleapis.com/pagespeedonline/v1/runPagespeed?screenshot=true&url=${formattedLink}`) 51 | .then(res => { 52 | const rawData = res.data.screenshot; 53 | if (rawData) { 54 | const imgData = rawData.data.replace(/_/g, '/').replace(/-/g, '+'); 55 | const screenshot = 'data:' + rawData.mime_type + ';base64,' + imgData; 56 | store.dispatch({ type: 'SEND_SCREENSHOTS', payload: { link, screenshot } }); 57 | } 58 | }); 59 | } 60 | }; 61 | 62 | export const nextPage = (): void => { 63 | store.dispatch((dispatch: any): any => { 64 | dispatch({ type: 'INCREMENT' }); 65 | axios.post(`/search/${store.getState().query}/${store.getState().counter}`).then(res => { 66 | dispatch({ type: 'SEND_RESULTS', payload: res.data }); 67 | screenGrab(); 68 | }); 69 | }); 70 | }; 71 | 72 | export const outline = (site: string): void => { 73 | store.dispatch((dispatch: any): any => { 74 | dispatch({ type: 'OUTLINE_LOADING' }); 75 | axios.post(`/outline/${site}`).then(res => dispatch({ type: 'OUTLINE', payload: res.data })); 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/About.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import '../style/About.css'; 4 | 5 | export const About: React.StatelessComponent = (): JSX.Element => ( 6 |
7 | 8 |

Spresso

9 |

Search

10 | 11 |
12 |

About

13 |

Source code on Github

14 |

15 | Spresso Search is a visual metasearch engine built using React, Redux and Express. It scrapes results from Google and obtains their meta-data 16 | using the node x-ray library. It also has a text-outline feature (powered by node-unfluff) that allows you to read the contents of a web page in clean, formatted text without leaving the Spresso Search site. 17 |

18 | 23 |
24 |
25 | ); 26 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import '../style/Loader.css'; 3 | 4 | // Source: https://codepen.io/rbv912/pen/dYbqLQ 5 | 6 | export const Loader: React.StatelessComponent = (): JSX.Element => ( 7 |
8 | 9 |
10 | ); 11 | -------------------------------------------------------------------------------- /src/components/Outline.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Loader } from './Loader'; 3 | import '../style/Outline.css'; 4 | 5 | interface IOutlineProp { 6 | outline?: { 7 | title?: string; 8 | text?: string; 9 | }; 10 | } 11 | 12 | export const Outline: React.StatelessComponent = ({ outline }: IOutlineProp): JSX.Element => { 13 | if (outline === 'outline loading...') { 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | return ( 21 |
22 |

{!!outline ? null : 'Spresso Text-Outline\n v.1.0.0'}

23 |

{!outline ? null : outline.title}

24 |

{!outline ? null : outline.text}

25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Result.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Tooltip from 'rc-tooltip'; 3 | import 'rc-tooltip/assets/bootstrap_white.css'; 4 | import '../style/Result.css'; 5 | import { outline } from '../actions'; 6 | import imgLoader from '../images/imgLoader.gif'; 7 | import document from '../images/document.svg'; 8 | 9 | export interface IData { 10 | title: string; 11 | link: string; 12 | snippet?: string; 13 | favicon?: string; 14 | pagemap?: {cse_image?: any}; 15 | } 16 | 17 | interface IResultProps { 18 | data: IData; 19 | screenshots: string[]; 20 | } 21 | 22 | const resolveImage = (pagemap: IData['pagemap'], link: string, screenshots: any[]) => { 23 | if (pagemap && pagemap.cse_image) { 24 | const [{src}] = pagemap.cse_image; 25 | return src; 26 | } else { 27 | for (let i = 0; i < screenshots.length; i++) { 28 | if (screenshots[i].link === link) { 29 | return screenshots[i].screenshot; 30 | } 31 | } 32 | return imgLoader; 33 | } 34 | }; 35 | 36 | export const Result = ({ data, screenshots }: IResultProps) => ( 37 |
38 | 39 | 40 | 41 |
42 | 43 | 47 | 48 |

49 | 50 | {data.title} 51 | 52 |

53 | } 57 | > 58 | outline outline(data.link)} /> 59 | 60 |
61 |

{data.snippet}

62 |
63 |
64 |
65 | ); 66 | -------------------------------------------------------------------------------- /src/components/ResultsList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as InfiniteScroll from 'react-infinite-scroll-component'; 3 | import { nextPage } from '../actions/index'; 4 | import { Result } from './Result'; 5 | import { IData } from './Result'; 6 | 7 | interface IResultsListProps { 8 | results: any[]; 9 | screenshots: string[]; 10 | } 11 | 12 | export const ResultsList = ({ results, screenshots }: IResultsListProps): JSX.Element => { 13 | const ResultsArray = []; 14 | const len = results.length; 15 | 16 | for (let i = 0; i < len; i++) { 17 | ResultsArray.push(); 18 | } 19 | 20 | return ( 21 | Loading...

} 26 | > 27 | {ResultsArray} 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/ResultsView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ResultsList } from './ResultsList'; 3 | import { Link } from 'react-router-dom'; 4 | import { Outline } from './Outline'; 5 | import { handleKey, search, setQuery } from '../actions'; 6 | import '../style/ResultsView.css'; 7 | import glass from '../images/glass.svg'; 8 | import { Loader } from './Loader'; 9 | 10 | export const ResultsView: React.StatelessComponent = ({ results, outline, screenshots, query, loadingStatus }: any): JSX.Element => { 11 | const mobile: string[] = ['Android', 'webOS', 'iPhone', 'iPad', 'iPod', 'BlackBerry']; 12 | return ( 13 |
14 |
15 | 16 |

Spresso

17 |

Search

18 | 19 | handleKey(e, 'reset')} 22 | onChange={e => setQuery(e)} 23 | /> 24 | search('reset')} className="glass" alt="magnifying glass" src={glass} /> 25 | 26 | About 27 | 28 |
29 | 30 | {loadingStatus === true 31 | ?
32 | : 33 | } 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/SearchBox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import '../style/SearchBox.css'; 4 | import { search, handleKey, setQuery } from '../actions/index'; 5 | import glass from '../images/glass.svg'; 6 | 7 | export const SearchBox: React.StatelessComponent = (): JSX.Element => ( 8 |
9 | 10 | About 11 | 12 | 13 |
14 |

Spresso

Search

15 | setQuery(e)} onKeyPress={e => handleKey(e)} autoFocus={true} /> 16 | magnifying glass 17 |
18 |
19 | ); 20 | -------------------------------------------------------------------------------- /src/containers/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { SearchBox } from '../components/SearchBox'; 4 | import { ResultsView } from '../components/ResultsView'; 5 | 6 | export interface IHomeProps { 7 | results?: any[]; 8 | loadingStatus?: boolean; 9 | outline?: any; 10 | query: string; 11 | counter?: number; 12 | screenshots?: any; 13 | } 14 | 15 | export const Home: React.StatelessComponent = ({ 16 | results, 17 | loadingStatus, 18 | outline, 19 | query, 20 | screenshots 21 | }: IHomeProps) => { 22 | if (loadingStatus === true) { 23 | return ; 24 | } else if (loadingStatus === false && !!results) { 25 | return ( 26 | 33 | ); 34 | } 35 | return ; 36 | }; 37 | 38 | const mapStateToProps = (state: IHomeProps): IHomeProps => { 39 | return { 40 | results: state.results, 41 | loadingStatus: state.loadingStatus, 42 | outline: state.outline, 43 | query: state.query, 44 | counter: state.counter, 45 | screenshots: state.screenshots 46 | }; 47 | }; 48 | 49 | export default connect(mapStateToProps)(Home); 50 | -------------------------------------------------------------------------------- /src/images/back-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/images/document.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/images/glass.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/images/imgLoader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshuaRabiu/spresso-search/dc2fee6c3f4b53f766f8dfb36638383df3617f9d/src/images/imgLoader.gif -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "europa"; 3 | src: url("https://use.typekit.net/af/4eabcf/00000000000000003b9b12fd/27/l?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff2"), url("https://use.typekit.net/af/4eabcf/00000000000000003b9b12fd/27/d?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("woff"), url("https://use.typekit.net/af/4eabcf/00000000000000003b9b12fd/27/a?primer=7cdcb44be4a7db8877ffa5c0007b8dd865b3bbc383831fe2ea177f62257a9191&fvd=n4&v=3") format("opentype"); 4 | font-style: normal; 5 | font-weight: 400; 6 | } 7 | *{ 8 | font-family: europa; 9 | font-size: 16px; 10 | } 11 | body { 12 | background: #fff; 13 | } 14 | a{ 15 | text-decoration: none; 16 | color: inherit; 17 | } 18 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import './reset.min.css'; 5 | import './index.css'; 6 | import { App } from './App'; 7 | import { Provider } from 'react-redux'; 8 | import { store } from './reducers/index'; 9 | import registerServiceWorker from './registerServiceWorker'; 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('root') 18 | ); 19 | registerServiceWorker(); 20 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reducers/counterReducer.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | const initialState = 0; 4 | export const counterReducer = (state = initialState, action: IAction) => { 5 | switch (action.type) { 6 | case 'INCREMENT': 7 | return state += 10; 8 | case 'RESET_RESULTS': 9 | return initialState; 10 | default: 11 | return state; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, applyMiddleware, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { counterReducer } from './counterReducer'; 4 | import { loadingStatusReducer } from './loadingStatus'; 5 | import { outlineReducer } from './outlineReducer'; 6 | import { queryReducer } from './queryReducer'; 7 | import { resultsReducer } from './resultsReducer'; 8 | import { screenshotsReducer } from './screenshotsReducer'; 9 | 10 | export interface IAction { 11 | type: string; 12 | payload?: any; 13 | } 14 | 15 | export const rootReducer = combineReducers({ 16 | results: resultsReducer, 17 | loadingStatus: loadingStatusReducer, 18 | outline: outlineReducer, 19 | query: queryReducer, 20 | counter: counterReducer, 21 | screenshots: screenshotsReducer 22 | }) 23 | 24 | const middleware = applyMiddleware(thunk) 25 | export const store = createStore(rootReducer, middleware) 26 | -------------------------------------------------------------------------------- /src/reducers/loadingStatus.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | export const loadingStatusReducer = (state = false, action: IAction) => { 4 | switch (action.type) { 5 | case 'LOADING_STATUS': 6 | return action.payload; 7 | default: 8 | return state; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/reducers/outlineReducer.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | export const outlineReducer = (state = '', action: IAction) => { 4 | switch (action.type) { 5 | case 'OUTLINE': 6 | return action.payload; 7 | case 'OUTLINE_LOADING': 8 | return 'outline loading...'; 9 | default: 10 | return state; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/reducers/queryReducer.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | export const queryReducer = (state = '', action: IAction) => { 4 | switch (action.type) { 5 | case 'SET_QUERY': 6 | return action.payload; 7 | default: 8 | return state; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/reducers/resultsReducer.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | const initialState = ''; 4 | 5 | export const resultsReducer = (state = initialState, action: IAction) => { 6 | switch (action.type) { 7 | case 'SEND_RESULTS': 8 | return !!state === true ? state.concat(action.payload) : action.payload; 9 | case 'RESET_RESULTS': 10 | return initialState; 11 | default: 12 | return state; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/reducers/screenshotsReducer.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from '.'; 2 | 3 | export const screenshotsReducer = (state = [], action: IAction) => { 4 | switch (action.type) { 5 | case 'SEND_SCREENSHOTS': 6 | return state.concat(action.payload); 7 | default: 8 | return state; 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/registerServiceWorker.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | // In production, we register a service worker to serve assets from local cache. 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 the 'N+1' visit to a page, since previously 7 | // cached resources are updated in the background. 8 | 9 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 10 | // This link also includes instructions on opting out of this behavior. 11 | 12 | const isLocalhost = Boolean( 13 | window.location.hostname === 'localhost' || 14 | // [::1] is the IPv6 localhost address. 15 | window.location.hostname === '[::1]' || 16 | // 127.0.0.1/8 is considered localhost for IPv4. 17 | window.location.hostname.match( 18 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 19 | ) 20 | ); 21 | 22 | export default function register() { 23 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 24 | // The URL constructor is available in all browsers that support SW. 25 | const publicUrl = new URL( 26 | process.env.PUBLIC_URL!, 27 | window.location.toString() 28 | ); 29 | if (publicUrl.origin !== window.location.origin) { 30 | // Our service worker won't work if PUBLIC_URL is on a different origin 31 | // from what our page is served on. This might happen if a CDN is used to 32 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 33 | return; 34 | } 35 | 36 | window.addEventListener('load', () => { 37 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 38 | 39 | if (isLocalhost) { 40 | // This is running on localhost. Lets check if a service worker still exists or not. 41 | checkValidServiceWorker(swUrl); 42 | 43 | // Add some additional logging to localhost, pointing developers to the 44 | // service worker/PWA documentation. 45 | navigator.serviceWorker.ready.then(() => { 46 | console.log( 47 | 'This web app is being served cache-first by a service ' + 48 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 49 | ); 50 | }); 51 | } else { 52 | // Is not local host. Just register service worker 53 | registerValidSW(swUrl); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | function registerValidSW(swUrl: string) { 60 | navigator.serviceWorker 61 | .register(swUrl) 62 | .then(registration => { 63 | registration.onupdatefound = () => { 64 | const installingWorker = registration.installing; 65 | if (installingWorker) { 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the old content will have been purged and 70 | // the fresh content will have been added to the cache. 71 | // It's the perfect time to display a 'New content is 72 | // available; please refresh.' message in your web app. 73 | console.log('New content is available; please refresh.'); 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // 'Content is cached for offline use.' message. 78 | console.log('Content is cached for offline use.'); 79 | } 80 | } 81 | }; 82 | } 83 | }; 84 | }) 85 | .catch(error => { 86 | console.error('Error during service worker registration:', error); 87 | }); 88 | } 89 | 90 | function checkValidServiceWorker(swUrl: string) { 91 | // Check if the service worker can be found. If it can't reload the page. 92 | fetch(swUrl) 93 | .then(response => { 94 | // Ensure service worker exists, and that we really are getting a JS file. 95 | if ( 96 | response.status === 404 || 97 | response.headers.get('content-type')!.indexOf('javascript') === -1 98 | ) { 99 | // No service worker found. Probably a different app. Reload the page. 100 | navigator.serviceWorker.ready.then(registration => { 101 | registration.unregister().then(() => { 102 | window.location.reload(); 103 | }); 104 | }); 105 | } else { 106 | // Service worker found. Proceed as normal. 107 | registerValidSW(swUrl); 108 | } 109 | }) 110 | .catch(() => { 111 | console.log( 112 | 'No internet connection found. App is running in offline mode.' 113 | ); 114 | }); 115 | } 116 | 117 | export function unregister() { 118 | if ('serviceWorker' in navigator) { 119 | navigator.serviceWorker.ready.then(registration => { 120 | registration.unregister(); 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/reset.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none} -------------------------------------------------------------------------------- /src/style/About.css: -------------------------------------------------------------------------------- 1 | .home-1, 2 | .home-2 { 3 | display: inline-block; 4 | } 5 | .home-1 { 6 | background-image: linear-gradient(to right, #ff5c1c 0%, #ffc04b 100%); 7 | -webkit-background-clip: text; 8 | -webkit-text-fill-color: transparent; 9 | margin-left: 23px; 10 | } 11 | .home-2 { 12 | color: #545454; 13 | padding-left: 5.5px; 14 | } 15 | .center { 16 | position: absolute; 17 | top: 50%; 18 | left: 50%; 19 | margin-right: -50%; 20 | transform: translate(-50%, -50%); 21 | width: 45vw; 22 | } 23 | .center a { 24 | color: #ffc04b; 25 | } 26 | .center a:hover { 27 | text-decoration: underline; 28 | } 29 | .center h4 { 30 | display: block; 31 | } 32 | ul, 33 | li { 34 | list-style: none; 35 | } 36 | ul, 37 | li::before { 38 | content: "- "; 39 | } 40 | -------------------------------------------------------------------------------- /src/style/Loader.css: -------------------------------------------------------------------------------- 1 | .loader-5 { 2 | height: 32px; 3 | width: 32px; 4 | margin: auto; 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | bottom: 80px; 9 | right: 0; 10 | -webkit-animation: loader-5-1 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 11 | animation: loader-5-1 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 12 | } 13 | @-webkit-keyframes loader-5-1 { 14 | 0% { 15 | -webkit-transform: rotate(0deg); 16 | } 17 | 100% { 18 | -webkit-transform: rotate(360deg); 19 | } 20 | } 21 | @keyframes loader-5-1 { 22 | 0% { 23 | transform: rotate(0deg); 24 | } 25 | 100% { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | .loader-5::before { 30 | content: ""; 31 | display: block; 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | bottom: 0; 36 | right: auto; 37 | margin: auto; 38 | width: 8px; 39 | height: 8px; 40 | background: #ffa500; 41 | border-radius: 50%; 42 | -webkit-animation: loader-5-2 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 43 | animation: loader-5-2 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 44 | } 45 | @-webkit-keyframes loader-5-2 { 46 | 0% { 47 | -webkit-transform: translate3d(0, 0, 0) scale(1); 48 | } 49 | 50% { 50 | -webkit-transform: translate3d(24px, 0, 0) scale(.5); 51 | } 52 | 100% { 53 | -webkit-transform: translate3d(0, 0, 0) scale(1); 54 | } 55 | } 56 | @keyframes loader-5-2 { 57 | 0% { 58 | transform: translate3d(0, 0, 0) scale(1); 59 | } 60 | 50% { 61 | transform: translate3d(24px, 0, 0) scale(.5); 62 | } 63 | 100% { 64 | transform: translate3d(0, 0, 0) scale(1); 65 | } 66 | } 67 | .loader-5::after { 68 | content: ""; 69 | display: block; 70 | position: absolute; 71 | top: 0; 72 | left: auto; 73 | bottom: 0; 74 | right: 0; 75 | margin: auto; 76 | width: 8px; 77 | height: 8px; 78 | background: #ffa500; 79 | border-radius: 50%; 80 | -webkit-animation: loader-5-3 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 81 | animation: loader-5-3 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 82 | } 83 | @-webkit-keyframes loader-5-3 { 84 | 0% { 85 | -webkit-transform: translate3d(0, 0, 0) scale(1); 86 | } 87 | 50% { 88 | -webkit-transform: translate3d(-24px, 0, 0) scale(.5); 89 | } 90 | 100% { 91 | -webkit-transform: translate3d(0, 0, 0) scale(1); 92 | } 93 | } 94 | @keyframes loader-5-3 { 95 | 0% { 96 | transform: translate3d(0, 0, 0) scale(1); 97 | } 98 | 50% { 99 | transform: translate3d(-24px, 0, 0) scale(.5); 100 | } 101 | 100% { 102 | transform: translate3d(0, 0, 0) scale(1); 103 | } 104 | } 105 | .loader-5 span { 106 | display: block; 107 | position: absolute; 108 | top: 0; 109 | left: 0; 110 | bottom: 0; 111 | right: 0; 112 | margin: auto; 113 | height: 32px; 114 | width: 32px; 115 | } 116 | .loader-5 span::before { 117 | content: ""; 118 | display: block; 119 | position: absolute; 120 | top: 0; 121 | left: 0; 122 | bottom: auto; 123 | right: 0; 124 | margin: auto; 125 | width: 8px; 126 | height: 8px; 127 | background: #ffa500; 128 | border-radius: 50%; 129 | -webkit-animation: loader-5-4 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 130 | animation: loader-5-4 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 131 | } 132 | @-webkit-keyframes loader-5-4 { 133 | 0% { 134 | -webkit-transform: translate3d(0, 0, 0) scale(1); 135 | } 136 | 50% { 137 | -webkit-transform: translate3d(0, 24px, 0) scale(.5); 138 | } 139 | 100% { 140 | -webkit-transform: translate3d(0, 0, 0) scale(1); 141 | } 142 | } 143 | @keyframes loader-5-4 { 144 | 0% { 145 | transform: translate3d(0, 0, 0) scale(1); 146 | } 147 | 50% { 148 | transform: translate3d(0, 24px, 0) scale(.5); 149 | } 150 | 100% { 151 | transform: translate3d(0, 0, 0) scale(1); 152 | } 153 | } 154 | .loader-5 span::after { 155 | content: ""; 156 | display: block; 157 | position: absolute; 158 | top: auto; 159 | left: 0; 160 | bottom: 0; 161 | right: 0; 162 | margin: auto; 163 | width: 8px; 164 | height: 8px; 165 | background: #ffa500; 166 | border-radius: 50%; 167 | -webkit-animation: loader-5-5 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 168 | animation: loader-5-5 2s cubic-bezier(0.770, 0.000, 0.175, 1.000) infinite; 169 | } 170 | @-webkit-keyframes loader-5-5 { 171 | 0% { 172 | -webkit-transform: translate3d(0, 0, 0) scale(1); 173 | } 174 | 50% { 175 | -webkit-transform: translate3d(0, -24px, 0) scale(.5); 176 | } 177 | 100% { 178 | -webkit-transform: translate3d(0, 0, 0) scale(1); 179 | } 180 | } 181 | @keyframes loader-5-5 { 182 | 0% { 183 | transform: translate3d(0, 0, 0) scale(1); 184 | } 185 | 50% { 186 | transform: translate3d(0, -24px, 0) scale(.5); 187 | } 188 | 100% { 189 | transform: translate3d(0, 0, 0) scale(1); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/style/Outline.css: -------------------------------------------------------------------------------- 1 | .outline { 2 | font-size: 16px; 3 | float: right; 4 | position: sticky; 5 | position: -webkit-sticky; 6 | top: 75px; 7 | display: inline-block; 8 | background: #fff; 9 | border: .009px solid #f2f2f2; 10 | box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.15); 11 | width: 41vw; 12 | height: 84vh; 13 | border-radius: 20px; 14 | margin-right: 15px; 15 | overflow: scroll; 16 | max-width: 100%; 17 | overflow-x: hidden; 18 | padding: 20px; 19 | line-height: 19pt; 20 | text-align: justify; 21 | } 22 | .outline h3 { 23 | font-style: italic; 24 | } 25 | .outline p { 26 | color: #545454; 27 | } 28 | .placeholder { 29 | opacity: 0.4; 30 | position: absolute; 31 | top: 50%; 32 | left: 50%; 33 | margin-right: -50%; 34 | transform: translate(-50%, -50%); 35 | } 36 | @media only screen and (min-device-width: 320px) and (max-device-width: 1024px) { 37 | .outline, 38 | .placeholder { 39 | display: none; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/style/Result.css: -------------------------------------------------------------------------------- 1 | h4 a { 2 | text-decoration: none; 3 | color: #ffa500; 4 | } 5 | 6 | h4 a:hover { 7 | text-decoration: underline; 8 | } 9 | h4 a:visited { 10 | color: #962a00; 11 | } 12 | .card { 13 | display: flex; 14 | position: relative; 15 | height: 174px; 16 | width: 50.5vw; 17 | border-radius: 20px; 18 | margin: 15px; 19 | background: #fff; 20 | box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.15); 21 | transition: all 300ms ease; 22 | } 23 | .card:hover { 24 | transform: scale(1.01); 25 | box-shadow: 0 15px 30px 0 rgba(0, 0, 0, 0.15), 0 5px 15px 0 rgba(0, 0, 0, 0.08); 26 | } 27 | .card-body { 28 | display: inline-block; 29 | margin-left: 8px; 30 | margin-right: 8px; 31 | border-bottom: 6px solid transparent; 32 | text-overflow: ellipsis; 33 | line-height: normal; 34 | overflow: hidden; 35 | } 36 | .title { 37 | color: #ffa500; 38 | max-width: 22vw; 39 | text-overflow: ellipsis; 40 | overflow: hidden; 41 | } 42 | .title:visited{ 43 | color: #962a00; 44 | } 45 | h4 { 46 | display: inline-block; 47 | white-space: nowrap; 48 | max-width: 45ch; 49 | } 50 | .preview { 51 | width: 21vw; 52 | height: 100%; 53 | border-top-left-radius: 20px; 54 | border-bottom-left-radius: 20px; 55 | } 56 | .favicon { 57 | position: relative; 58 | top: 1vh; 59 | width: 18px; 60 | height: 18px; 61 | float: left; 62 | clear: both; 63 | margin: 13px; 64 | } 65 | .wrap { 66 | margin-top: -21px; 67 | } 68 | .description { 69 | color: #545454; 70 | min-height: 0; 71 | padding: 0 10px 10px 10px; 72 | } 73 | .icon { 74 | position: relative; 75 | bottom: 27px; 76 | vertical-align: bottom; 77 | display: inline-block; 78 | padding-left: 10px; 79 | width: 17px; 80 | height: 17px; 81 | cursor: pointer; 82 | } 83 | @media only screen and (min-device-width: 320px) and (max-device-width: 480px) { 84 | * { 85 | font-size: 13px; 86 | } 87 | .top-bar { 88 | text-align: center; 89 | } 90 | .glass { 91 | height: 27px; 92 | } 93 | input { 94 | font-size: 16px; 95 | } 96 | .card { 97 | margin: 10px 5px 10px 3px; 98 | width: 98vw; 99 | } 100 | .card-body { 101 | border-bottom: 10px solid transparent; 102 | } 103 | .title { 104 | max-width: 18ch; 105 | text-overflow: ellipsis; 106 | overflow: hidden; 107 | } 108 | .preview { 109 | min-width: 41vw; 110 | height: 174px; 111 | } 112 | .about-bar { 113 | display: none; 114 | } 115 | .icon { 116 | display: none; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/style/ResultsView.css: -------------------------------------------------------------------------------- 1 | .invisible { 2 | display: none; 3 | } 4 | .loading-text { 5 | color: #545454; 6 | margin: 15px; 7 | } 8 | .top-bar { 9 | position: sticky; 10 | top: 0px; 11 | z-index: 500; 12 | background: #fff; 13 | height: 60px; 14 | width: 100%; 15 | box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.10); 16 | } 17 | .top-bar input { 18 | margin-left: 5.4vw; 19 | height: 5.5vh; 20 | margin-top: 7px; 21 | box-shadow: none; 22 | border: 1px solid lightgrey; 23 | display: inline-flex; 24 | } 25 | .top-bar h3 { 26 | display: inline; 27 | } 28 | .top-bar h3:hover { 29 | cursor: pointer; 30 | } 31 | .heading-1 { 32 | margin-left: 23px; 33 | margin-right: 5.5px; 34 | background-image: linear-gradient(to right, #ff5c1c 0%, #ffc04b 100%); 35 | -webkit-background-clip: text; 36 | -webkit-text-fill-color: transparent; 37 | } 38 | .heading-2 { 39 | color: #545454; 40 | } 41 | .about-bar { 42 | float: right; 43 | padding: 15px; 44 | /* position: relative; 45 | left: 40px; 46 | top: 15px; */ 47 | } 48 | .about-bar:hover { 49 | text-decoration: underline; 50 | } 51 | .load-wrap { 52 | position: absolute; 53 | top: 50%; 54 | left: 24%; 55 | margin-right: -50%; 56 | transform: translate(-50%, -50%); 57 | } 58 | -------------------------------------------------------------------------------- /src/style/SearchBox.css: -------------------------------------------------------------------------------- 1 | .home { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | margin-right: -50%; 6 | transform: translate(-50%, -50%); 7 | line-height: normal; 8 | } 9 | .home-logo { 10 | font-weight: 1000; 11 | display: inline; 12 | text-align: center; 13 | background-image: linear-gradient(to right, #ff5c1c 0%, #ffc04b 100%); 14 | -webkit-background-clip: text; 15 | -webkit-text-fill-color: transparent; 16 | } 17 | .home-logo-2 { 18 | display: inline; 19 | padding-left: 5.5px; 20 | text-align: center; 21 | color: #545454; 22 | margin-right: 50px; 23 | } 24 | input { 25 | width: 522px; 26 | height: 48px; 27 | margin: auto; 28 | font-size: inherit; 29 | padding: 0 58px 0 28px; 30 | border-radius: 30px; 31 | border: none; 32 | border: 1.3px solid #f2f2f2; 33 | outline: 0; 34 | box-shadow: 2px 4px 8px 0 rgba(0, 0, 0, 0.15); 35 | transition: all 300ms ease; 36 | } 37 | .glass { 38 | display: inline-block; 39 | position: relative; 40 | right: 50px; 41 | top: 8px; 42 | width: 30px; 43 | height: 30px; 44 | } 45 | .glass:hover { 46 | cursor: pointer; 47 | } 48 | @media only screen and (min-device-width: 320px) and (max-device-width: 480px) { 49 | * { 50 | max-width: 100%; 51 | } 52 | .home { 53 | text-align: center; 54 | } 55 | input { 56 | width: 61vw; 57 | margin-top: 2vh; 58 | -webkit-appearance: none; 59 | -moz-appearance: none; 60 | appearance: none; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { expect } = require('chai'); 3 | 4 | describe('Spresso Search', async function(){ 5 | let browser; 6 | let page; 7 | 8 | const opts = { 9 | args: [ '--start-fullscreen' ] 10 | }; 11 | 12 | this.timeout(20000); 13 | before(async function(){ 14 | browser = await puppeteer.launch(opts); 15 | }); 16 | 17 | beforeEach(async function(){ 18 | page = await browser.newPage(); 19 | await page.goto('http://spresso-search.herokuapp.com/', { 20 | waitUntil: 'load' 21 | }); 22 | }); 23 | 24 | afterEach(async function(){ 25 | await page.close(); 26 | }); 27 | 28 | after(async function(){ 29 | await browser.close(); 30 | }); 31 | 32 | it('Returns search results when a query is typed and enter is pressed ', async function(){ 33 | await page.click('input'); 34 | await page.keyboard.type('tesla'); 35 | await page.keyboard.press('Enter'); 36 | const cards = await page.waitForSelector('.card'); 37 | expect(!!cards).to.be.true; 38 | }); 39 | 40 | it('Returns search results when a query is typed and the magnifying glass icon is pressed', async function(){ 41 | await page.click('input'); 42 | await page.keyboard.type('tesla'); 43 | await page.click('.glass'); 44 | const cards = await page.waitForSelector('.card'); 45 | expect(!!cards).to.be.true; 46 | }); 47 | 48 | it('Renders more results on scroll down (Infinite Scroll)', async function(){ 49 | await page.click('input'); 50 | await page.keyboard.type('tesla'); 51 | await page.keyboard.press('Enter'); 52 | await page.waitForSelector('.card'); 53 | const initialNumOfCards = await page.evaluate(() => { 54 | return document.getElementsByClassName('card').length; 55 | }); 56 | await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); 57 | await page.waitFor(7000); 58 | const numOfCardsAfterScroll = await page.evaluate(() => { 59 | return document.getElementsByClassName('card').length; 60 | }); 61 | expect(numOfCardsAfterScroll).to.be.greaterThan(initialNumOfCards); 62 | }); 63 | 64 | it('Text outline returns a title and text', async function(){ 65 | await page.click('input'); 66 | // Ensures that only text-rich results are returned 67 | await page.keyboard.type('tesla site:wikipedia.com'); 68 | await page.keyboard.press('Enter'); 69 | await page.waitForSelector('.card'); 70 | await page.click('div.card:nth-child(1) > div:nth-child(2) > img:nth-child(3)'); 71 | await page.waitForSelector('.outline > p:nth-child(3)'); 72 | const outlineTitle = await page.evaluate(() => { 73 | return document.querySelector('.outline > h3:nth-child(2)').innerText; 74 | }); 75 | const outlineText = await page.evaluate(() => { 76 | return document.querySelector('.outline > p:nth-child(3)').innerText; 77 | }); 78 | expect(!!outlineTitle, !!outlineText).to.be.true; 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build/dist", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["esnext", "dom"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "rootDir": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "importHelpers": true, 18 | "strictNullChecks": true, 19 | "suppressImplicitAnyIndexErrors": true, 20 | "noUnusedLocals": true 21 | }, 22 | "exclude": [ 23 | "node_modules", 24 | "build", 25 | "scripts", 26 | "acceptance-tests", 27 | "webpack", 28 | "jest", 29 | "src/setupTests.ts", 30 | "server", 31 | "test" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": [ 5 | "config/**/*.js", 6 | "node_modules/**/*.ts", 7 | "coverage/lcov-report/*.js", 8 | "server" 9 | ] 10 | }, 11 | "rules": { 12 | "prefer-for-of": false, 13 | "grouped-imports": false, 14 | "object-literal-sort-keys": false, 15 | "ordered-imports": false, 16 | "jsx-no-lambda": false, 17 | "no-console": false, 18 | "only-arrow-functions": false 19 | } 20 | } 21 | --------------------------------------------------------------------------------