├── nodemon.json ├── .gitignore ├── src ├── client │ ├── favicon.ico │ ├── index.template.html │ ├── components │ │ ├── Loading.js │ │ ├── ScrollUp.js │ │ ├── Background.js │ │ ├── Description.js │ │ ├── NoData.js │ │ ├── Notification.js │ │ ├── Search.js │ │ ├── Translate.js │ │ ├── Image.js │ │ └── Display.js │ ├── index.js │ ├── polyfills │ │ ├── find.js │ │ └── promise.js │ ├── hooks │ │ └── UseDebouncy.js │ ├── containers │ │ ├── Home.js │ │ ├── Header.js │ │ └── Gallery.js │ └── reducer.js ├── assets │ ├── fonts │ │ ├── Inter-UI-Regular.woff │ │ ├── Inter-UI-Regular.woff2 │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 │ ├── translations │ │ ├── translations.js │ │ ├── en.json │ │ ├── pt.json │ │ ├── fr.json │ │ ├── de.json │ │ ├── es.json │ │ └── it.json │ └── styles │ │ ├── global.css │ │ └── font-awesome.css └── server │ ├── utils │ ├── algolia.js │ ├── database.js │ ├── crontab.js │ └── middleware.js │ ├── api │ ├── images.routes.js │ ├── images.model.js │ └── images.controller.js │ ├── index.js │ ├── crons │ ├── indexing.js │ ├── cleaning.js │ └── scraping.js │ └── bots │ ├── scraper.js │ ├── negative.js │ ├── kaboom.js │ ├── twitter.js │ ├── pexels.js │ └── unsplash.js ├── .env.example ├── .babelrc ├── .eslintrc ├── README.md ├── webpack.config.js └── package.json /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src/server/"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | database.json 4 | config.json 5 | .env 6 | todo 7 | 8 | -------------------------------------------------------------------------------- /src/client/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/client/favicon.ico -------------------------------------------------------------------------------- /src/assets/fonts/Inter-UI-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/Inter-UI-Regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/Inter-UI-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/Inter-UI-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /src/assets/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /src/assets/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/assets/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/quentindutot/motada-photos-browser/HEAD/src/assets/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ADMIN_KEY="" 2 | MONGODB_URI="" 3 | ALGOLIA_APPLICATION_ID="" 4 | ALGOLIA_SEARCH_KEY="" 5 | ALGOLIA_ADMIN_KEY="" 6 | SCRAPING_CRON="00 */2 * * *" 7 | CLEANING_CRON="00 */2 * * *" 8 | INDEXING_CRON="00 */2 * * *" 9 | -------------------------------------------------------------------------------- /src/server/utils/algolia.js: -------------------------------------------------------------------------------- 1 | const algoliasearch = require('algoliasearch') 2 | 3 | const client = algoliasearch(process.env.ALGOLIA_APPLICATION_ID, process.env.ALGOLIA_ADMIN_KEY) 4 | 5 | module.exports = client.initIndex 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-proposal-class-properties", 8 | "@babel/plugin-transform-runtime" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/translations/translations.js: -------------------------------------------------------------------------------- 1 | import de from './de.json' 2 | import en from './en.json' 3 | import es from './es.json' 4 | import fr from './fr.json' 5 | import it from './it.json' 6 | import pt from './pt.json' 7 | 8 | export default { 9 | de, en, es, fr, it, pt, 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "env": { 4 | "es6": true, 5 | "browser": true, 6 | "node": true 7 | }, 8 | "plugins": ["react"], 9 | "rules": { 10 | "no-console": "off", 11 | "react/jsx-filename-extension": "off", 12 | "no-plusplus": "off" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/server/api/images.routes.js: -------------------------------------------------------------------------------- 1 | const Images = require('./images.controller.js') 2 | 3 | module.exports = (router) => { 4 | 5 | router.get('/api/images/:id?', Images.find) 6 | router.patch('/api/images/:id?', Images.update) 7 | router.delete('/api/images/:id?', Images.delete) 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/client/index.template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Motada 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/client/components/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | const Loading = ({ loading = false }) => ( 5 | loading ? ( 6 |
7 | ) : null 8 | ) 9 | 10 | const mapState = state => ({ 11 | loading: state.loading, 12 | }) 13 | 14 | export default connect(mapState, null)(Loading) 15 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const database = require('./utils/database.js') 4 | const crontab = require('./utils/crontab.js') 5 | const api = require('./api/images.routes.js') 6 | 7 | const server = require('./utils/middleware.js') 8 | const port = process.env.PORT || 8080 9 | 10 | database() 11 | api(server) 12 | crontab() 13 | 14 | server.listen(port, () => console.log(`Listening on port ${port}!`)) 15 | -------------------------------------------------------------------------------- /src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Inter UI'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: 6 | local('Inter UI'), 7 | url(../fonts/Inter-UI-Regular.woff2) format('woff2'), 8 | url(../fonts/Inter-UI-Regular.woff) format('woff'); 9 | } 10 | 11 | html { 12 | width: 100%; 13 | height: 100%; 14 | font-family: 'Inter UI', sans-serif; 15 | } 16 | 17 | body { 18 | width: 100%; 19 | margin: auto; 20 | padding: auto; 21 | text-align: center; 22 | } 23 | -------------------------------------------------------------------------------- /src/client/components/ScrollUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { translate } from 'react-i18nify' 3 | import ScrollUpLib from 'react-scroll-up' 4 | 5 | const ScrollUp = () => ( 6 | 7 | 12 | 13 | ) 14 | 15 | export default ScrollUp 16 | -------------------------------------------------------------------------------- /src/server/crons/indexing.js: -------------------------------------------------------------------------------- 1 | const algolia = require('../utils/algolia.js') 2 | const Images = require('../api/images.model.js') 3 | 4 | module.exports = async () => { 5 | 6 | const randomImages = await Images.getSamples(100) 7 | 8 | const ids = randomImages.map(({ _id }) => _id) 9 | const objects = randomImages.map(({ _id, title }) => ({ objectID: _id, title })) 10 | 11 | algolia('images') 12 | .saveObjects(objects) 13 | .then(console.log('images indexed on algolia:', ids)) 14 | .catch(console.error) 15 | } 16 | -------------------------------------------------------------------------------- /src/server/utils/database.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | const mongoose = require('mongoose') 3 | 4 | const url = process.env.MONGODB_URI || 'mongodb://localhost:27017/motada' 5 | const depreciations = { 6 | useNewUrlParser: true, 7 | useFindAndModify: false, 8 | useUnifiedTopology: true, 9 | useCreateIndex: true, 10 | } 11 | 12 | mongoose.Promise = global.Promise 13 | 14 | mongoose.connect(url, depreciations) 15 | .then(() => console.log('Database connected!')) 16 | .catch(() => console.log('Error connecting to database!')) 17 | } 18 | -------------------------------------------------------------------------------- /src/server/utils/crontab.js: -------------------------------------------------------------------------------- 1 | const cron = require('node-cron') 2 | 3 | const scraping = require('../crons/scraping.js') 4 | const cleaning = require('../crons/cleaning.js') 5 | const indexing = require('../crons/indexing.js') 6 | 7 | module.exports = () => { 8 | 9 | // scraping cron 10 | cron.schedule(process.env.SCRAPING_CRON || '00 */2 * * *', scraping) 11 | 12 | // cleaning cron 13 | cron.schedule(process.env.CLEANING_CRON || '00 */2 * * *', cleaning) 14 | 15 | // indexing cron 16 | cron.schedule(process.env.INDEXING_CRON || '00 */2 * * *', indexing) 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/client/components/Background.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ParticleAnimation from 'react-particle-animation' 3 | 4 | const Background = () => { 5 | 6 | const isIE = /MSIE|Trident/.test(window.navigator.userAgent) 7 | 8 | return isIE ? null : ( 9 | 16 | ) 17 | } 18 | 19 | export default Background 20 | -------------------------------------------------------------------------------- /src/client/components/Description.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Description = () => ( 4 |
5 | {['unsplash.com', 'pexels.com', 'negativespace.co', 'kaboompics.com'].map(source => ( 6 | 13 | {source} 14 | 15 | ))} 16 |
17 | ) 18 | 19 | export default Description 20 | -------------------------------------------------------------------------------- /src/server/utils/middleware.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const express = require('express') 3 | const bodyParser = require('body-parser') 4 | const cleanUrl = require('ssl-express-www') 5 | 6 | const server = express() 7 | 8 | server.use(cleanUrl) 9 | 10 | server.use(express.static(path.join(__dirname, '../../../dist'))) 11 | 12 | server.use(bodyParser.urlencoded({ extended: true })) 13 | server.use(bodyParser.json()) 14 | 15 | server.use((req, res, next) => { 16 | res.header('Access-Control-Allow-Origin', 'http://localhost:3000') 17 | next() 18 | }) 19 | 20 | server.get('/', (req, res) => { 21 | res.sendFile(path.join(__dirname, '../../../dist', 'index.html')) 22 | }) 23 | 24 | module.exports = server 25 | -------------------------------------------------------------------------------- /src/server/api/images.model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const algolia = require('../utils/algolia.js') 3 | 4 | const schema = mongoose.Schema({ 5 | url: { type: String, default: '' }, 6 | title: { type: String, default: '' }, 7 | source: { type: String, default: '' }, 8 | tags: { type: [String], default: [], text: true }, 9 | click: { type: Number, default: 0 }, 10 | }, { 11 | timestamps: true 12 | }) 13 | 14 | schema.statics.getSamples = function(size) { 15 | return this.aggregate([{ $sample: { size: Number(size) || 1 } }]) 16 | } 17 | 18 | schema.statics.cleanDelete = function(id) { 19 | algolia('images').deleteObject(id) 20 | return this.findByIdAndRemove(id) 21 | } 22 | 23 | module.exports = mongoose.model('images', schema) 24 | -------------------------------------------------------------------------------- /src/client/components/NoData.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { translate } from 'react-i18nify' 4 | 5 | const NoData = ({ search = '', loading = false }) => ( 6 | loading ? null : ( 7 |
8 | 9 |

10 | {translate('errors.no_results')} 11 |

12 | 13 | {search && localStorage.getItem('motada_language') !== 'en' && ( 14 |

15 | {translate('errors.no_results_tip')} 16 |

17 | )} 18 | 19 |
20 | ) 21 | ) 22 | 23 | const mapState = state => ({ 24 | search: state.search, 25 | loading: state.loading, 26 | }) 27 | 28 | export default connect(mapState, null)(NoData) 29 | -------------------------------------------------------------------------------- /src/assets/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "en", 3 | "flag": "GB", 4 | "language": "english", 5 | "header": { 6 | "title": "Over %{count} free and high-res images", 7 | "default_title": "Over thousands free and high-res images" 8 | }, 9 | "tooltips": { 10 | "search": "Search here...", 11 | "clear_search": "Clear", 12 | "translations": "Translations", 13 | "keywords": "Related keywords", 14 | "download": "Download", 15 | "save": "Save", 16 | "copy": "Copy", 17 | "close": "Close", 18 | "scroll_to_top": "Scroll to top" 19 | }, 20 | "errors": { 21 | "no_translation": "Oops no translation available !", 22 | "no_results": "Oops no results !", 23 | "no_results_tip": "Sorry, the search engine only works in English for now...", 24 | "unknow": "Oops an error has occurred !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/translations/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "pt", 3 | "flag": "PT", 4 | "language": "português", 5 | "header": { 6 | "title": "Mais de %{count} imagens gratuitas e de alta resolução", 7 | "default_title": "Mais de milhares de imagens gratuitas e de alta resolução" 8 | }, 9 | "tooltips": { 10 | "search": "Procure aqui...", 11 | "clear_search": "Limpar", 12 | "translations": "Traduções", 13 | "keywords": "Palavras-chave relacionadas", 14 | "download": "Baixar", 15 | "save": "Salvar", 16 | "copy": "Copiar", 17 | "close": "Fechar", 18 | "scroll_to_top": "Rolar para cima" 19 | }, 20 | "errors": { 21 | "no_translation": "Ups ! sem tradução disponível !", 22 | "no_results": "Ups ! sem resultados !", 23 | "no_results_tip": "Desculpe, o motor de busca só funciona em inglês por enquanto...", 24 | "unknow": "Ups ! Há um erro !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "fr", 3 | "flag": "FR", 4 | "language": "français", 5 | "header": { 6 | "title": "Plus de %{count} images libres et gratuites", 7 | "default_title": "Plusieurs milliers d'images libres et gratuites" 8 | }, 9 | "tooltips": { 10 | "search": "Rechercher ici...", 11 | "clear_search": "Effacer", 12 | "translations": "Traductions", 13 | "keywords": "Mots-clés liés", 14 | "download": "Télécharger", 15 | "save": "Enregistrer", 16 | "copy": "Copier", 17 | "close": "Fermer", 18 | "scroll_to_top": "Défiler vers le haut" 19 | }, 20 | "errors": { 21 | "no_translation": "Oups aucune traduction disponible !", 22 | "no_results": "Oups aucun résultat !", 23 | "no_results_tip": "Désolé actuellement le moteur de recherche ne fonctionne qu'en anglais...", 24 | "unknow": "Oups une erreur est survenue !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "de", 3 | "flag": "DE", 4 | "language": "deutsch", 5 | "header": { 6 | "title": "Über %{count} kostenlose und hochauflösende Bilder", 7 | "default_title": "Über tausende kostenlose und hochauflösende Bilder" 8 | }, 9 | "tooltips": { 10 | "search": "Suche hier...", 11 | "clear_search": "Löschen", 12 | "translations": "Übersetzungen", 13 | "keywords": "Verwandte Stichwörter", 14 | "download": "Herunterladen", 15 | "save": "Speichern", 16 | "copy": "Kopieren", 17 | "close": "Schließen", 18 | "scroll_to_top": "Scrolle nach oben" 19 | }, 20 | "errors": { 21 | "no_translation": "Ups keine Übersetzung verfügbar !", 22 | "no_results": "Hoppla, keine Ergebnisse !", 23 | "no_results_tip": "Entschuldigung, die Suchmaschine funktioniert momentan nur auf Englisch...", 24 | "unknow": "Ups ein Fehler ist aufgetreten !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/translations/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "es", 3 | "flag": "ES", 4 | "language": "español", 5 | "header": { 6 | "title": "Más de %{count} imágenes gratuitas y de alta resolución", 7 | "default_title": "Más de miles de imágenes gratuitas y de alta resolución" 8 | }, 9 | "tooltips": { 10 | "search": "Buscar aquí...", 11 | "clear_search": "Limpiar", 12 | "translations": "Traducciones", 13 | "keywords": "Palabras claves relacionadas", 14 | "download": "Descargar", 15 | "save": "Guardar", 16 | "copy": "Copiar", 17 | "close": "Cerrar", 18 | "scroll_to_top": "Vuelve al comienzo" 19 | }, 20 | "errors": { 21 | "no_translation": "¡ Uy, ninguna traducción disponible !", 22 | "no_results": "¡ Uy no hay resultados !", 23 | "no_results_tip": "Lo sentimos, el motor de búsqueda solo funciona en inglés por ahora...", 24 | "unknow": "¡ Uy, ha ocurrido un error !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/translations/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": "it", 3 | "flag": "IT", 4 | "language": "italiano", 5 | "header": { 6 | "title": "Oltre %{count} immagini gratuite e ad alta risoluzione", 7 | "default_title": "Oltre mille immagini gratuito e ad alta risoluzione" 8 | }, 9 | "tooltips": { 10 | "search": "Cerca qui...", 11 | "clear_search": "Cancellare", 12 | "translations": "Traduzioni", 13 | "keywords": "Parole chiave correlate", 14 | "download": "Scaricare", 15 | "save": "Registrare", 16 | "copy": "Copia", 17 | "close": "Chiudere", 18 | "scroll_to_top": "Scorri in alto" 19 | }, 20 | "errors": { 21 | "no_translation": "Oops nessuna traduzione disponibile !", 22 | "no_results": "Spiacenti alcun risultato !", 23 | "no_results_tip": "Siamo spiacenti, il motore di ricerca funziona solo in inglese per ora...", 24 | "unknow": "Spiacenti, si è verificato un errore !" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/server/crons/cleaning.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios') 2 | const Images = require('../api/images.model.js') 3 | 4 | module.exports = async () => { 5 | 6 | const randomImages = await Images.getSamples(100) 7 | 8 | randomImages.forEach((randomImage) => { 9 | axios.head(randomImage.url).then(async () => { 10 | 11 | // delete duplicate images 12 | const similarImages = await Images.find({ url: randomImage.url }) 13 | const duplicateImages = similarImages.filter(similarImage => similarImage._id.toString() != randomImage._id.toString()) 14 | duplicateImages.forEach(async (duplicateImage) => { 15 | await Images.cleanDelete(duplicateImage._id) 16 | console.log('duplicate image deleted:', duplicateImage._id) 17 | }) 18 | 19 | }).catch(async () => { 20 | 21 | // delete unreachable image 22 | await Images.cleanDelete(randomImage._id) 23 | console.log('unreachable image deleted:', randomImage._id) 24 | 25 | }) 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/client/components/Notification.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { connect } from 'react-redux' 3 | import { updateNotification } from '../reducer' 4 | import Slide from 'react-reveal/Slide' 5 | 6 | const Notification = ({ notification = '', updateNotification = () => {} }) => { 7 | 8 | useEffect(() => { 9 | if (!notification) return 10 | const clearNotification = () => updateNotification('') 11 | setTimeout(clearNotification, 6000) 12 | return () => clearTimeout(clearNotification) 13 | }, [notification]) 14 | 15 | return notification ? ( 16 | 17 |

18 | {notification} 19 |

20 |
21 | ) : null 22 | } 23 | 24 | const mapState = state => ({ 25 | notification: state.notification, 26 | }) 27 | 28 | const mapDispatch = dispatch => ({ 29 | updateNotification: notification => dispatch(updateNotification(notification)), 30 | }) 31 | 32 | export default connect(mapState, mapDispatch)(Notification) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # motada-photos-browser 2 | full-stack javascript single page appplication | [motada.cc](https://motada.cc) 3 | 4 | ## Concept 5 | 6 | Motada is a free and high-res images browser. 7 | Best images from best plateformes centralized on a single website with no account creation, license, credits... 8 | 9 | Daily new images from unsplash.com, pexels.com, pixabay.com, freerangestock.com, flickr.com, gratisography.com, freeimages.co.uk, picjumbo.com, morguefile.com, pikwizard.com. 10 | 11 | ## History 12 | 13 | 2018 preview: 14 | 15 | 16 | 17 | ## Stack 18 | 19 | #### `frontend`: [React](https://github.com/facebook/react) - [Redux](https://github.com/reduxjs/redux) - [TailwindCSS](https://github.com/tailwindlabs/tailwindcss) (previously [Material-UI](https://github.com/mui-org/material-ui)) - [Webpack](https://github.com/webpack/webpack) 20 | 21 | #### `backend`: [Node.js](https://nodejs.org/en/) - [Express](https://github.com/expressjs/express) - [MongoDB](https://github.com/mongodb/mongo) - [Puppeteer](https://github.com/GoogleChrome/puppeteer) - [Algolia](https://github.com/algolia) 22 | 23 | #### `boilerplate`: [simple-react-full-stack](https://github.com/crsandeep/simple-react-full-stack) 24 | -------------------------------------------------------------------------------- /src/client/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import ReactGA from 'react-ga' 4 | import { setTranslations, setLocale } from 'react-i18nify' 5 | import { Provider } from 'react-redux' 6 | import { createStore } from 'redux' 7 | import translations from '../assets/translations/translations' 8 | import Home from './containers/Home' 9 | import reducer from './reducer' 10 | 11 | // bundle needed styles (tree shaked with webpack) 12 | import '../assets/styles/font-awesome.css' 13 | import '../assets/styles/tailwind.css' 14 | import '../assets/styles/global.css' 15 | 16 | // bundle needed polyfills 17 | import './polyfills/promise' 18 | import './polyfills/find' 19 | 20 | // add supported locales 21 | setTranslations(translations) 22 | 23 | // extract and validate user language 24 | const detectedLanguage = (localStorage.getItem('motada_language') || navigator.language || navigator.userLanguage || 'en').substring(0,2) 25 | const validatedLanguage = translations[detectedLanguage] ? detectedLanguage : 'en' 26 | setLocale(validatedLanguage) 27 | 28 | // init google analytics tracking 29 | if(window.location.href.indexOf('localhost') === -1) { 30 | ReactGA.initialize('UA-127688890-1') 31 | ReactGA.pageview(window.location.pathname + window.location.search) 32 | } 33 | 34 | // render react application 35 | ReactDOM.render( 36 | 37 | 38 | , 39 | document.getElementById('root'), 40 | ) 41 | -------------------------------------------------------------------------------- /src/client/polyfills/find.js: -------------------------------------------------------------------------------- 1 | // https://tc39.github.io/ecma262/#sec-array.prototype.find 2 | if (!Array.prototype.find) { 3 | Object.defineProperty(Array.prototype, 'find', { 4 | value: function(predicate) { 5 | // 1. Let O be ? ToObject(this value). 6 | if (this == null) { 7 | throw TypeError('"this" is null or not defined'); 8 | } 9 | 10 | var o = Object(this); 11 | 12 | // 2. Let len be ? ToLength(? Get(O, "length")). 13 | var len = o.length >>> 0; 14 | 15 | // 3. If IsCallable(predicate) is false, throw a TypeError exception. 16 | if (typeof predicate !== 'function') { 17 | throw TypeError('predicate must be a function'); 18 | } 19 | 20 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. 21 | var thisArg = arguments[1]; 22 | 23 | // 5. Let k be 0. 24 | var k = 0; 25 | 26 | // 6. Repeat, while k < len 27 | while (k < len) { 28 | // a. Let Pk be ! ToString(k). 29 | // b. Let kValue be ? Get(O, Pk). 30 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). 31 | // d. If testResult is true, return kValue. 32 | var kValue = o[k]; 33 | if (predicate.call(thisArg, kValue, k, o)) { 34 | return kValue; 35 | } 36 | // e. Increase k by 1. 37 | k++; 38 | } 39 | 40 | // 7. Return undefined. 41 | return undefined; 42 | }, 43 | configurable: true, 44 | writable: true 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/client/hooks/UseDebouncy.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | 3 | // 16 ms its time on 1 frame 4 | const FRAME_MS = 16 5 | 6 | /** 7 | * @param fn - Debounce callback. 8 | * @param wait - Number of milliseconds to delay. 9 | * @param deps - Array values that the debounce depends (like as useEffect). 10 | */ 11 | const useDebouncy = (fn = () => {}, wait = 1000, deps = []) => { 12 | const defaultWait = wait || 0 13 | const defaultDeps = deps || [] 14 | const rafId = useRef(0) 15 | const timeStart = useRef(0) 16 | const callback = useRef(fn) 17 | const isFirstRender = useRef(true) 18 | 19 | const renderFrame = useRef((timeNow) => { 20 | // call will be after the first frame - requires subtracting 16 ms for more accurate timing. 21 | timeStart.current = timeStart.current || timeNow - FRAME_MS 22 | 23 | // call next rAF if time is not up 24 | if (timeNow - timeStart.current < defaultWait) { 25 | rafId.current = requestAnimationFrame(renderFrame.current) 26 | return 27 | } 28 | 29 | callback.current() 30 | }) 31 | 32 | // set new callback if it updated 33 | useEffect(() => { 34 | callback.current = fn 35 | }, [fn]) 36 | 37 | // call update if deps changes 38 | useEffect(() => { 39 | if (isFirstRender.current) { 40 | isFirstRender.current = false 41 | return () => {} 42 | } 43 | 44 | timeStart.current = 0 45 | rafId.current = requestAnimationFrame(renderFrame.current) 46 | 47 | return () => { 48 | cancelAnimationFrame(rafId.current) 49 | } 50 | }, defaultDeps) 51 | } 52 | 53 | export default useDebouncy 54 | -------------------------------------------------------------------------------- /src/server/api/images.controller.js: -------------------------------------------------------------------------------- 1 | const algolia = require('../utils/algolia.js') 2 | const Images = require('./images.model.js') 3 | 4 | exports.find = async (req, res) => { 5 | const { query } = req 6 | const { random, search } = query 7 | let result = false 8 | 9 | // api/images?count 10 | if (Object.prototype.hasOwnProperty.call(query, 'count')) { 11 | result = { 12 | count: await Images.estimatedDocumentCount() 13 | } 14 | } 15 | 16 | // api/images?random=3 17 | if (Object.prototype.hasOwnProperty.call(query, 'random')) { 18 | result = { 19 | random: await Images.getSamples(random) 20 | } 21 | } 22 | 23 | // api/images?search=beautiful car 24 | if (Object.prototype.hasOwnProperty.call(query, 'search')) { 25 | const { hits } = await algolia('images').search(search, { page: 0, hitsPerPage: 1000 }) 26 | const ids = hits.map(({ objectID }) => objectID) 27 | result = { 28 | search: await Images.find({ _id: { $in: ids } }) 29 | } 30 | } 31 | 32 | res.send(JSON.stringify(result)) 33 | } 34 | 35 | exports.update = async (req, res) => { 36 | const { params, body } = req 37 | let result = false 38 | 39 | if (params.id) { 40 | const image = await Images.findByIdAndUpdate(params.id, body, { new: true }) 41 | if (image) result = true 42 | } 43 | 44 | res.send(JSON.stringify(result)) 45 | } 46 | 47 | exports.delete = async (req, res) => { 48 | const { params } = req 49 | let result = false 50 | 51 | if (params.id) { 52 | const image = await Images.cleanDelete(params.id) 53 | if (image) result = true 54 | } 55 | 56 | res.send(JSON.stringify(result)) 57 | } 58 | -------------------------------------------------------------------------------- /src/server/bots/scraper.js: -------------------------------------------------------------------------------- 1 | const Puppeteer = require("puppeteer") 2 | const Pexels = require("./pexels") 3 | const Unsplash = require("./unsplash") 4 | const Negative = require("./negative") 5 | const Kaboom = require("./kaboom") 6 | 7 | class Scraper { 8 | 9 | constructor() { 10 | this.config = { 11 | headless: true, 12 | options: ["--disable-gpu", "--no-sandbox", "--window-size=1024x768"], 13 | viewport: { "width": 1024, "height": 768 }, 14 | header: { "Accept-Language": "en" }, 15 | } 16 | } 17 | 18 | async start() { 19 | const { headless, options, viewport, header } = this.config 20 | 21 | this.browser = await Puppeteer.launch({ 22 | headless: headless, 23 | args: options, 24 | defaultViewport: viewport, 25 | }) 26 | 27 | this.page = await this.browser.newPage() 28 | 29 | await this.page.setExtraHTTPHeaders(header) 30 | await this.page.setViewport(viewport) 31 | // await this.page.setUserAgent(this.config.puppeteer.chrome_useragent === "" ? (await this.browser.userAgent()).replace("Headless", "") : this.config.puppeteer.chrome_useragent) 32 | } 33 | 34 | async api(flow) { 35 | switch (flow) { 36 | case 'unsplash': 37 | this.api = new Unsplash(this.page) 38 | break 39 | case 'negative': 40 | this.api = new Negative(this.page) 41 | break 42 | case 'kaboom': 43 | this.api = new Kaboom(this.page) 44 | break 45 | case 'pexels': default: 46 | this.api = new Pexels(this.page) 47 | break 48 | } 49 | } 50 | 51 | async stop() { 52 | await this.browser.newPage() 53 | await this.browser.close() 54 | } 55 | 56 | } 57 | 58 | module.exports = Scraper -------------------------------------------------------------------------------- /src/client/components/Search.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { translate } from 'react-i18nify' 3 | import { connect } from 'react-redux' 4 | import useDebouncy from '../hooks/UseDebouncy' 5 | import { makeSearch } from '../reducer' 6 | 7 | const Search = ({ search = '', makeSearch = () => {} }) => { 8 | 9 | const [value, setValue] = useState(search) 10 | useDebouncy( 11 | () => makeSearch(value), 12 | 500, 13 | [value], 14 | ) 15 | 16 | useEffect(() => { 17 | if (search == value) return 18 | setValue(search) 19 | }, [search]) 20 | 21 | return ( 22 |
23 |