├── chrome-extension ├── app.js ├── images │ ├── photo-depot-128px.png │ ├── photo-depot-16px.png │ └── photo-depot-48px.png ├── styles.scss ├── popup.html ├── popup.js ├── manifest.json └── eventPage.js ├── .gitignore ├── jest-teardown.js ├── client └── src │ ├── favicon.ico │ ├── photo-depot-logo.png │ ├── containers │ ├── Footer.jsx │ ├── SearchBar.jsx │ ├── Header.jsx │ ├── MainGallery.jsx │ └── SideBar.jsx │ ├── store.js │ ├── reducers │ ├── index.js │ └── photosReducer.js │ ├── components │ ├── Login.js │ ├── PhotosAll.jsx │ └── Photo.jsx │ ├── index.js │ ├── constants │ └── actionTypes.js │ ├── index.html │ ├── App.js │ ├── actions │ └── actions.js │ └── index.scss ├── jest-setup.js ├── .babelrc ├── server ├── utils │ ├── websockets.js │ └── queries.js ├── controllers │ ├── websocketController.js │ ├── userController.js │ ├── oauthController.js │ ├── tagController.js │ └── imageController.js ├── models │ └── model.js ├── routes │ ├── tags.js │ ├── images.js │ └── api.js ├── server.js └── wsserver.js ├── pull_request_template.md ├── README.md ├── __tests__ ├── googleAuth.js └── server.js ├── pg_database.sql ├── webpack.config.js ├── package.json └── pg_database_test.sql /chrome-extension/app.js: -------------------------------------------------------------------------------- 1 | // alert('Connected "app.js"') -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | package-lock.json 4 | dist 5 | -------------------------------------------------------------------------------- /jest-teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = async (globalConfig) => { 2 | testServer.close(); 3 | }; 4 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geodudes/photo-depot/HEAD/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/photo-depot-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geodudes/photo-depot/HEAD/client/src/photo-depot-logo.png -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | global.testServer = await require('./server/server'); 3 | }; 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /chrome-extension/images/photo-depot-128px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geodudes/photo-depot/HEAD/chrome-extension/images/photo-depot-128px.png -------------------------------------------------------------------------------- /chrome-extension/images/photo-depot-16px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geodudes/photo-depot/HEAD/chrome-extension/images/photo-depot-16px.png -------------------------------------------------------------------------------- /chrome-extension/images/photo-depot-48px.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geodudes/photo-depot/HEAD/chrome-extension/images/photo-depot-48px.png -------------------------------------------------------------------------------- /client/src/containers/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Footer = () => { 4 | return ( 5 | 6 | ) 7 | } 8 | 9 | export default Footer; -------------------------------------------------------------------------------- /client/src/containers/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SearchBar = () => { 4 | return ( 5 | 6 | ) 7 | } 8 | 9 | export default SearchBar; -------------------------------------------------------------------------------- /client/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension'; 3 | import reducers from './reducers/index'; 4 | 5 | const store = createStore(reducers, composeWithDevTools()); 6 | 7 | export default store; 8 | -------------------------------------------------------------------------------- /server/utils/websockets.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | //function for sending to all clients 3 | sendMessage: (json, clients) => { 4 | Object.keys(clients).map((client) => { 5 | clients[client].sendUTF(JSON.stringify(json)); 6 | }); 7 | } 8 | 9 | } 10 | -------------------------------------------------------------------------------- /client/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import photosReducer from './photosReducer'; 3 | // import reducers written in other files here 4 | 5 | const reducers = combineReducers({ photos: photosReducer }); 6 | // add object with each property being a reducer 7 | 8 | export default reducers; 9 | -------------------------------------------------------------------------------- /pull_request_template.md: -------------------------------------------------------------------------------- 1 | **What is the problem you were trying to solve?** 2 | 3 | 4 | 5 | **What is your solution and why did you solve this problem the way you did?** 6 | 7 | 8 | 9 | **Provide any additional notes on testing this functionality:** 10 | 11 | 12 | 13 | **Screenshots / gifs of working solution:** 14 | 15 | 16 | -------------------------------------------------------------------------------- /chrome-extension/styles.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | font-family: Arial, Helvetica, sans-serif; 9 | } 10 | 11 | #popup { 12 | padding: 15px; 13 | height: 100px; 14 | width: 200px; 15 | overflow-wrap: break-word; 16 | } 17 | 18 | h2 { 19 | color: #2270d0; 20 | } 21 | -------------------------------------------------------------------------------- /client/src/containers/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Login from '../components/Login'; 3 | 4 | const Header = () => { 5 | return ( 6 |
7 | 8 |

Photo Depot

9 | 10 |
11 | ) 12 | } 13 | 14 | export default Header; -------------------------------------------------------------------------------- /server/controllers/websocketController.js: -------------------------------------------------------------------------------- 1 | const { 2 | sendMessage 3 | } = require('../utils/websockets') 4 | 5 | const websocketController = {}; 6 | 7 | websocketController.sendImage = (req, res, next) => { 8 | 9 | sendMessage({ 10 | data: res.locals.websocket, 11 | type: "newimage" 12 | }, res.locals.clients) 13 | return next(); 14 | } 15 | 16 | module.exports = websocketController; 17 | -------------------------------------------------------------------------------- /client/src/components/Login.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Login() { 4 | 5 | /* 6 | This is the button that sends the initial OAuth authentication request 7 | */ 8 | 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; -------------------------------------------------------------------------------- /server/models/model.js: -------------------------------------------------------------------------------- 1 | const { 2 | Pool 3 | } = require('pg'); 4 | 5 | const DB_URL = process.env.NODE_ENV !== 'test' ? process.env.PG_URI : process.env.PG_URI_TEST 6 | 7 | const pool = new Pool({ 8 | connectionString: DB_URL, 9 | connectionLimit: 300, 10 | }); 11 | 12 | module.exports = { 13 | query: (text, params, callback) => { 14 | console.log('executed query', text); 15 | return pool.query(text, params, callback); 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | import React from 'react'; 3 | import { 4 | render 5 | } from 'react-dom'; 6 | import App from './App'; 7 | 8 | import { 9 | Provider 10 | } from 'react-redux'; 11 | import store from './store'; 12 | 13 | render( < 14 | React.StrictMode > 15 | < 16 | Provider store = { 17 | store 18 | } > 19 | < 20 | App / > 21 | < 22 | /Provider> < / 23 | React.StrictMode > , 24 | document.getElementById('root') 25 | ); 26 | -------------------------------------------------------------------------------- /client/src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const GET_PHOTOS = 'GET_PHOTOS'; 2 | export const ADD_PHOTO = 'ADD_PHOTO'; 3 | export const DELETE_PHOTO = 'DELETE_PHOTO'; 4 | export const ADD_RATING = 'ADD_RATING'; 5 | 6 | export const GET_TAGS = 'GET_TAGS'; 7 | export const INPUT_TAG = 'INPUT_TAG'; 8 | export const ADD_TAG_TYPE = 'ADD_TAG_TYPE'; 9 | export const ADD_TAG_PHOTO = 'ADD_TAG_PHOTO'; 10 | export const REMOVE_TAG = 'REMOVE_TAG'; 11 | export const FILTER_BY_TAG = 'FILTER_BY_TAG'; 12 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Photo Depot 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /chrome-extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Photo Depot Popup 7 | 8 | 9 | 10 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /chrome-extension/popup.js: -------------------------------------------------------------------------------- 1 | // Event listener to handle incoming message from context menu. This contains the image url. 2 | chrome.runtime.onMessage.addListener( 3 | function(request, sender, sendResponse) { 4 | if (request.msg === "imageUrl_sent") { 5 | // Extract url and display 6 | const shortenedUrl = request.data.urlId[0].substring(0, 20).concat('...') 7 | document.querySelector('#image-address').innerText = shortenedUrl; 8 | 9 | // Extract image id and display 10 | const imageId = request.data.urlId[1]; 11 | document.querySelector('#image-id').innerText = imageId; 12 | } 13 | } 14 | ); -------------------------------------------------------------------------------- /server/controllers/userController.js: -------------------------------------------------------------------------------- 1 | const db = require('../models/model'); 2 | const queries = require('../utils/queries'); 3 | const jwtDecode = require('jwt-decode'); 4 | 5 | const userController = {}; 6 | 7 | // =================================== // 8 | 9 | userController.createUser = (req, res, next) => { 10 | const { email, name, sub } = jwtDecode(res.locals.token); // gives us email, name, sub -- which is the unique ID of the user's google account 11 | 12 | // create user in the database 13 | 14 | res.locals.userInfo = { email, name, sub } 15 | return next(); 16 | }; 17 | 18 | // =================================== // 19 | 20 | module.exports = userController; -------------------------------------------------------------------------------- /server/routes/tags.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | 4 | const tagController = require('../controllers/tagController') 5 | 6 | router.get('/', tagController.getTags, (req, res) => { 7 | return res.status(200).json(res.locals.data); 8 | }) 9 | 10 | router.post('/', tagController.addTag, (req, res) => { 11 | return res.status(200).json(res.locals.data); 12 | }) 13 | 14 | router.put('/:tagid', tagController.updateTag, (req, res) => { 15 | return res.status(200).json({}); 16 | }) 17 | 18 | router.delete('/:tagid', tagController.deleteTag, (req, res) => { 19 | return res.status(200).json({}); 20 | }) 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /server/routes/images.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const imageController = require('../controllers/imageController') 4 | const websocketController = require('../controllers/websocketController') 5 | 6 | router.get('/', imageController.getImages, (req, res) => { 7 | return res.status(200).json(res.locals.data); 8 | }) 9 | 10 | router.post('/', imageController.addImage, websocketController.sendImage, (req, res) => { 11 | return res.status(200).json(res.locals.data); 12 | }) 13 | 14 | router.put('/:photoid', imageController.updateImage, (req, res) => { 15 | return res.status(200).json({}); 16 | }) 17 | 18 | router.delete('/:photoid', imageController.deleteImage, (req, res) => { 19 | return res.status(200).json({}); 20 | }) 21 | 22 | module.exports = router; 23 | -------------------------------------------------------------------------------- /client/src/components/PhotosAll.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Photo from './Photo.jsx' 3 | 4 | const PhotosAll = (props) => { 5 | const { photos, filteredPhotos } = props; 6 | 7 | const displayPhotos = (photoArr) => { 8 | const photoGallery = []; 9 | console.log('photoArr', photoArr) 10 | for (let i = photoArr.length - 1; i >= 0; i--) { 11 | photoGallery.push( 12 | 18 | ); 19 | } 20 | return photoGallery; 21 | } 22 | console.log(filteredPhotos) 23 | const photoGallery = filteredPhotos.length ? displayPhotos(filteredPhotos) : displayPhotos(photos); 24 | 25 | 26 | 27 | return ( 28 |
29 | {photoGallery} 30 |
31 | ) 32 | } 33 | 34 | export default PhotosAll; -------------------------------------------------------------------------------- /chrome-extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Photo Depot", 4 | "version": "1.0", 5 | "description": "Click and save your image urls and view your collection at Photo Depot.", 6 | "content_scripts": [ 7 | { 8 | "matches": [ 9 | "" 10 | ], 11 | "js": [ 12 | "./app.js" 13 | ], 14 | "css": [ 15 | "./styles.scss" 16 | ] 17 | } 18 | ], 19 | "icons": { 20 | "128": "./images/photo-depot-128px.png" 21 | }, 22 | "browser_action": { 23 | "default_title": "Tool Tip: TEST default_title", 24 | "default_popup": "./popup.html", 25 | "default_icon": { 26 | "16": "./images/photo-depot-16px.png", 27 | "48": "./images/photo-depot-48px.png", 28 | "128": "./images/photo-depot-128px.png" 29 | } 30 | }, 31 | "background": { 32 | "scripts": [ 33 | "eventPage.js" 34 | ], 35 | "persistent": false 36 | }, 37 | "permissions": [ 38 | "contextMenus" 39 | ] 40 | } -------------------------------------------------------------------------------- /chrome-extension/eventPage.js: -------------------------------------------------------------------------------- 1 | //==========="Background" Scripts===========// 2 | let contextMenuItem = { 3 | id: "photoDepot", 4 | title: "Photo Depot", 5 | contexts: ["image"] 6 | }; 7 | 8 | // Create context menu 9 | chrome.contextMenus.create(contextMenuItem); 10 | 11 | // Event listener for when contect menu item is selected 12 | chrome.contextMenus.onClicked.addListener(image => { 13 | const imageUrl = image.srcUrl; 14 | // Send request to server with image url - i.e. image.src.Url 15 | fetch('http://localhost:3000/images', { 16 | method: 'POST', 17 | headers: { 18 | 'Content-Type': 'application/json' 19 | }, 20 | body: JSON.stringify({ 21 | url: imageUrl 22 | }) 23 | }) 24 | .then(res => res.json()) 25 | .then(res => { 26 | // Send image url and response id, respectively, to popup 27 | chrome.runtime.sendMessage({ 28 | msg: "imageUrl_sent", 29 | data: { 30 | urlId: [imageUrl, res.photoid], 31 | } 32 | }); 33 | }) 34 | .catch(err => console.log('Error: ', err)) 35 | }) 36 | 37 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Header from './containers/Header.jsx'; 3 | import SearchBar from './containers/SearchBar.jsx'; 4 | import SideBar from './containers/SideBar.jsx'; 5 | import MainGallery from './containers/MainGallery.jsx'; 6 | import Footer from './containers/Footer.jsx'; 7 | import { Container, Row, Col } from 'react-bootstrap' 8 | 9 | function App() { 10 | return ( 11 |
12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | {/* */} 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {/*
*/} 34 | 35 | 36 | 37 |
38 | ); 39 | } 40 | 41 | export default App; -------------------------------------------------------------------------------- /server/utils/queries.js: -------------------------------------------------------------------------------- 1 | const queries = {}; 2 | 3 | //IMAGES 4 | queries.getImages = ` 5 | SELECT * 6 | FROM photos 7 | WHERE userid=$1` 8 | 9 | queries.addImage = ` 10 | INSERT INTO photos(url, userid, date, rating) 11 | VALUES($1, $2, $3, $4) 12 | RETURNING photoid` 13 | 14 | queries.updateImage = ` 15 | UPDATE photos 16 | SET rating=$2 17 | WHERE photoid=$1`; 18 | 19 | queries.deleteImage = ` 20 | DELETE FROM photos 21 | WHERE photoid=$1` 22 | 23 | //TAGS 24 | queries.getAllImageTags = ` 25 | SELECT t.tagid, t.tag, pt.photoid 26 | FROM phototags pt 27 | JOIN tags t 28 | ON t.tagid = pt.tagid 29 | WHERE pt.userid=$1` 30 | 31 | queries.getTags = ` 32 | SELECT tags.tag, tags.tagid 33 | FROM tags 34 | WHERE userid = $1 ` 35 | 36 | queries.addTag = ` 37 | INSERT INTO tags(tag, userid) 38 | VALUES($1, $2) 39 | RETURNING tagid ` 40 | 41 | queries.updateTag = ` 42 | INSERT INTO phototags(userid, photoid, tagid) 43 | VALUES($1, $2, $3) 44 | ` 45 | 46 | queries.deleteTag = ` 47 | DELETE FROM phototags 48 | WHERE userid = $1 AND photoid = $2 AND tagid = $3 ` 49 | 50 | module.exports = queries; 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | Photo Depot is a personal photo repository and Chrome extension. 3 | * Users load the extension and save image links to their personal database (PostgreSQL) through the Photo Depot context menu button 4 | * Images are deposited into the Photo Depot website in real-time through websockets where they can be organized, tagged, and filtered. 5 | ## Getting Started 6 | ### Prerequisites 7 | Ensure that you are running the latest version of npm 8 | ```sh 9 | npm install npm@latest -g 10 | ``` 11 | ### Installation 12 | 1. Clone the repo 13 | ```sh 14 | git clone https://github.com/geodudes/photo-depot.git 15 | ``` 16 | 2. Install NPM packages 17 | ```sh 18 | npm install 19 | ``` 20 | 3. Create a `.env` and save your PostgreSQL URI: 21 | ```sh 22 | PG_URI= 23 | ``` 24 | ## Testing 25 | Testing is implemented through Jest and Supertest. Configurations are found in `__tests__` at the root directory. 26 | Run the following terminal command to execute: 27 | ```sh 28 | npm run test 29 | ``` 30 | ## Contributing 31 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 32 | 1. Fork the Project 33 | 2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) 34 | 3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) 35 | 4. Push to the Branch (`git push origin feature/AmazingFeature`) 36 | 5. Open a Pull Request 37 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const oauthController = require("../controllers/oauthController"); 4 | const userController = require("../controllers/userController") 5 | 6 | /* 7 | Request comes from Login container 8 | 9 | This route asks for permissions from a user to retrieve an access token 10 | 1. /getAuthURL 11 | - This harvests a URL which we then use to redirect the user to a consent page 12 | 2. User will then give permission on consent page 13 | 3. Google then redirects the user to the redirect URL that we provided ** see process.env.REDIRECT_URL 14 | - This will contain a code query parameter ** /oauthcallback?code={authorizationCode} 15 | 4. /login/google 16 | - This is where we currently have the redirect routed 17 | - Harvests the access token and uses a command to set the credentials 18 | - At this point, you are logged in 19 | */ 20 | 21 | router.get('/getAuthURL', oauthController.getAuthURL, (req, res) => { 22 | return res.redirect(res.locals.url); 23 | }); 24 | 25 | router.get('/login/google', 26 | oauthController.getAuthCode, // get access token 27 | oauthController.setSSIDCookie, // set a cookie in browser 28 | userController.createUser, // create a user with this information 29 | (req, res) => { 30 | return res.redirect('http://localhost:8080/'); 31 | }); 32 | 33 | router.use('/logout', oauthController.removeCookie, (req, res) => { 34 | return res.redirect('/'); 35 | }); 36 | 37 | module.exports = router; -------------------------------------------------------------------------------- /__tests__/googleAuth.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const server = 'http://localhost:3000'; 3 | const app = require('express'); 4 | 5 | /** 6 | * Read the docs! https://www.npmjs.com/package/supertest 7 | */ 8 | 9 | describe('Google OAuth process', () => { 10 | 11 | describe('Contains Valid Google OAuth Credentials', () => { 12 | const CLIENT_ID = process.env.CLIENT_ID; 13 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 14 | const REDIRECT_URL = process.env.REDIRECT_URL; 15 | it('shows that you have your credentials in an ENV file', () => { 16 | expect(typeof CLIENT_ID).toBe('string'); 17 | expect(typeof CLIENT_SECRET).toBe('string'); 18 | expect(typeof REDIRECT_URL).toBe('string'); 19 | }); 20 | it('client_id is valid', () => { 21 | expect(CLIENT_ID.slice(-4)).toBe('.com'); 22 | }) 23 | }); 24 | 25 | describe('/api', () => { 26 | const correctURL = 'https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&response_type=code&prompt=consent&client_id=265226410890-ccp33d5sp4nom25nvdf6pq3eq1bet230.apps.googleusercontent.com&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Flogin%2Fgoogle' 27 | it('getAuthURL Redirects with correct URL', (done) => { 28 | return request(server) 29 | .get('/api/getAuthURL') 30 | .expect(`Found. Redirecting to ${correctURL}`, done) 31 | }); 32 | }); 33 | 34 | }); -------------------------------------------------------------------------------- /client/src/actions/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | export const getPhotos = (photos) => ({ 4 | type: types.GET_PHOTOS, 5 | payload: photos, 6 | }); 7 | 8 | export const appendPhoto = (photo) => ({ 9 | type: types.ADD_PHOTO, 10 | payload: photo, 11 | }); 12 | 13 | export const deletePhoto = (photoId) => ({ 14 | type: types.DELETE_PHOTO, 15 | payload: photoId, 16 | }); 17 | 18 | export const addRating = ({ 19 | photoId, 20 | rating 21 | }) => ({ 22 | type: types.ADD_RATING, 23 | payload: { 24 | photoId, 25 | rating 26 | }, 27 | }); 28 | 29 | export const getTags = (tags) => ({ 30 | type: types.GET_TAGS, 31 | payload: tags, 32 | }); 33 | 34 | export const inputTag = (input) => ({ 35 | type: types.INPUT_TAG, 36 | payload: input, 37 | }); 38 | 39 | // Add tag to array in state 40 | export const addTagType = (tagObj) => ({ 41 | type: types.ADD_TAG_TYPE, 42 | payload: tagObj, 43 | }); 44 | 45 | export const addTagPhoto = (photoId, tagObj) => ({ 46 | type: types.ADD_TAG_PHOTO, 47 | payload: { photoId, tagObj }, 48 | }); 49 | 50 | // Need to wait to get new tagId from the server (database) 51 | export const addTag = ({ photoId, newTag: { tagId, tag } }) => ({ 52 | type: types.ADD_TAG, 53 | payload: { photoId, newTag }, 54 | }); 55 | 56 | export const removeTag = ({ photoId, trashTag: { tagId, tag } }) => ({ 57 | type: types.REMOVE_TAG, 58 | payload: { photoId, trashTag }, 59 | }); 60 | 61 | export const filterByTag = (tagName) => ({ 62 | type: types.FILTER_BY_TAG, 63 | payload: tagName, 64 | }); 65 | -------------------------------------------------------------------------------- /pg_database.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS phototags 2 | CASCADE; 3 | DROP TABLE IF EXISTS tags 4 | CASCADE; 5 | DROP TABLE IF EXISTS photos 6 | CASCADE; 7 | DROP TABLE IF EXISTS users 8 | CASCADE; 9 | 10 | CREATE TABLE users 11 | ( 12 | "id" serial PRIMARY KEY, 13 | "userid" varchar NOT NULL, 14 | "name" varchar NOT NULL CHECK ( name <> ''), 15 | UNIQUE ( userid ) 16 | ); 17 | 18 | SELECT setval('users_id_seq', 1, false); 19 | 20 | CREATE TABLE photos 21 | ( 22 | "photoid" serial PRIMARY KEY, 23 | "url" varchar NOT NULL CHECK (url <> ''), 24 | "userid" varchar NOT NULL, 25 | "date" varchar NOT NULL, 26 | "rating" smallint, 27 | UNIQUE ( url ), 28 | CONSTRAINT fk_user FOREIGN KEY 29 | ( userid ) REFERENCES users 30 | ( userid ) ON 31 | DELETE CASCADE 32 | ); 33 | 34 | SELECT setval('photos_photoid_seq', 1, false); 35 | 36 | CREATE TABLE tags 37 | ( 38 | "tagid" serial PRIMARY KEY, 39 | "tag" varchar NOT NULL CHECK ( tag <> ''), 40 | "userid" varchar NOT NULL, 41 | UNIQUE ( tag ), 42 | CONSTRAINT fk_user FOREIGN KEY ( userid ) REFERENCES users ( userid ) ON DELETE CASCADE 43 | ); 44 | 45 | SELECT setval('tags_tagid_seq', 1, false); 46 | 47 | CREATE TABLE phototags 48 | ( 49 | "userid" varchar NOT NULL, 50 | "photoid" bigint NOT NULL, 51 | "tagid" bigint NOT NULL, 52 | CONSTRAINT fk_photo FOREIGN KEY ( photoid ) REFERENCES photos ( photoid ) ON DELETE CASCADE, 53 | CONSTRAINT fk_user FOREIGN KEY ( userid ) REFERENCES users ( userid ) ON DELETE CASCADE, 54 | CONSTRAINT fk_tag FOREIGN KEY ( tagid ) REFERENCES tags ( tagid ) ON DELETE CASCADE 55 | ); 56 | -------------------------------------------------------------------------------- /client/src/containers/MainGallery.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as actions from '../actions/actions'; 4 | import PhotosAll from '../components/PhotosAll' 5 | import { 6 | w3cwebsocket as W3CWebSocket 7 | } from "websocket"; 8 | 9 | //initialize websocket connection 10 | const client = new W3CWebSocket('ws://localhost:3000'); 11 | 12 | 13 | const mapStateToProps = state => ({ 14 | photos: state.photos.photos, 15 | filteredPhotos: state.photos.filteredPhotos, 16 | }); 17 | 18 | const mapDispatchToProps = dispatch => ({ 19 | handleGetPhotos: (photos) => dispatch(actions.getPhotos(photos)), 20 | handleAppendPhoto: (photo) => dispatch(actions.appendPhoto(photo)) 21 | }); 22 | 23 | const MainGallery = (props) => { 24 | useEffect(() => { 25 | //websockets 26 | client.onopen = () => { 27 | client.send(JSON.stringify({ 28 | type: "getimages" 29 | })) 30 | console.log('WebSocket Client Connected'); 31 | } 32 | client.onmessage = (message) => { 33 | const { data, type } = JSON.parse(message.data); 34 | if (type === "getimages") { 35 | data && props.handleGetPhotos(data) 36 | } else if (type === "newimage") { 37 | data && props.handleAppendPhoto(data) 38 | } 39 | } 40 | }, []); 41 | 42 | return ( 43 | 49 | ) 50 | } 51 | 52 | export default connect(mapStateToProps, mapDispatchToProps)(MainGallery); 53 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const { 4 | CleanWebpackPlugin 5 | } = require('clean-webpack-plugin'); 6 | const Dotenv = require('dotenv-webpack'); 7 | 8 | module.exports = { 9 | mode: process.env.NODE_ENV, 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: 'bundle.js', 13 | // publicPath: '/dist/', 14 | }, 15 | devServer: { 16 | port: 8080, 17 | publicPath: '/dist/', 18 | contentBase: './client/src', 19 | proxy: { 20 | '/images': 'http://localhost:3000', 21 | '/tags': 'http://localhost:3000', 22 | '/api': 'http://localhost:3000', 23 | }, 24 | hot: true, 25 | }, 26 | entry: path.resolve(__dirname, './client/src/index.js'), 27 | module: { 28 | rules: [{ 29 | test: /.(js|jsx)$/, 30 | exclude: /node_modules/, 31 | use: { 32 | loader: 'babel-loader', 33 | }, 34 | }, 35 | { 36 | test: /.(css|scss)$/, 37 | use: [ 38 | 'style-loader', 39 | 'css-loader', 40 | 'sass-loader' 41 | ] 42 | }, 43 | { 44 | test: /.(png|svg|jpg|gif|woff|woff2|eot|ttf|otf|ico)$/, 45 | use: [ 46 | 'file-loader', 47 | ], 48 | }, 49 | ] 50 | }, 51 | resolve: { 52 | extensions: ['.js', '.jsx',] 53 | }, 54 | plugins: [ 55 | new HtmlWebpackPlugin({ 56 | template: './client/src/index.html', 57 | favicon: './client/src/favicon.ico', 58 | // logo: './client/src/photo-depot-logo.png' 59 | }), 60 | new CleanWebpackPlugin(), 61 | new Dotenv(), 62 | 63 | ] 64 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "photo-depot", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "start": "NODE_ENV=production nodemon server/server.js", 7 | "build": "NODE_ENV=production webpack", 8 | "dev": "concurrently \"webpack-dev-server --open --hot\" \"NODE_ENV=development nodemon server/server.js\"", 9 | "test": "jest --verbose" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "description": "", 15 | "jest": { 16 | "globalSetup": "./jest-setup.js", 17 | "globalTeardown": "./jest-teardown.js" 18 | }, 19 | "dependencies": { 20 | "bootstrap": "^4.5.2", 21 | "concurrently": "^5.3.0", 22 | "cookie-parser": "^1.4.5", 23 | "dotenv": "^8.2.0", 24 | "dotenv-webpack": "^3.0.0", 25 | "express": "^4.17.1", 26 | "googleapis": "^60.0.1", 27 | "jest": "^26.4.2", 28 | "jwt-decode": "^3.0.0-beta.2", 29 | "pg": "^8.3.3", 30 | "react": "^16.13.1", 31 | "react-bootstrap": "^1.3.0", 32 | "react-dom": "^16.13.1", 33 | "react-redux": "^7.2.1", 34 | "redux": "^4.0.5", 35 | "supertest": "^5.0.0", 36 | "websocket": "^1.0.32" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.11.6", 40 | "@babel/plugin-transform-runtime": "^7.11.5", 41 | "@babel/preset-env": "^7.11.5", 42 | "@babel/preset-react": "^7.10.4", 43 | "babel-loader": "^8.1.0", 44 | "babel-polyfill": "^6.26.0", 45 | "clean-webpack-plugin": "^3.0.0", 46 | "css-loader": "^4.3.0", 47 | "html-webpack-plugin": "^4.5.0", 48 | "node-sass": "^4.14.1", 49 | "nodemon": "^2.0.4", 50 | "redux-devtools-extension": "^2.13.8", 51 | "sass": "^1.26.11", 52 | "sass-loader": "^10.0.2", 53 | "style-loader": "^1.2.1", 54 | "webpack": "^4.44.2", 55 | "webpack-cli": "^3.3.12", 56 | "webpack-dev-server": "^3.11.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pg_database_test.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS phototags 2 | CASCADE; 3 | DROP TABLE IF EXISTS tags 4 | CASCADE; 5 | DROP TABLE IF EXISTS photos 6 | CASCADE; 7 | DROP TABLE IF EXISTS users 8 | CASCADE; 9 | 10 | CREATE TABLE users 11 | ( 12 | "id" serial PRIMARY KEY, 13 | "userid" varchar NOT NULL, 14 | "name" varchar NOT NULL CHECK ( name <> ''), 15 | UNIQUE ( userid ) 16 | ); 17 | 18 | SELECT setval('users_id_seq', 1, false); 19 | 20 | CREATE TABLE photos 21 | ( 22 | "photoid" serial PRIMARY KEY, 23 | "url" varchar NOT NULL CHECK (url <> ''), 24 | "userid" varchar NOT NULL, 25 | "date" varchar NOT NULL, 26 | "rating" smallint, 27 | UNIQUE ( url ), 28 | CONSTRAINT fk_user FOREIGN KEY 29 | ( userid ) REFERENCES users 30 | ( userid ) ON 31 | DELETE CASCADE 32 | ); 33 | 34 | SELECT setval('photos_photoid_seq', 1, false); 35 | 36 | CREATE TABLE tags 37 | ( 38 | "tagid" serial PRIMARY KEY, 39 | "tag" varchar NOT NULL CHECK ( tag <> ''), 40 | "userid" varchar NOT NULL, 41 | UNIQUE ( tag ), 42 | CONSTRAINT fk_user FOREIGN KEY ( userid ) REFERENCES users ( userid ) ON DELETE CASCADE 43 | ); 44 | 45 | SELECT setval('tags_tagid_seq', 1, false); 46 | 47 | CREATE TABLE phototags 48 | ( 49 | "userid" varchar NOT NULL, 50 | "photoid" bigint NOT NULL, 51 | "tagid" bigint NOT NULL, 52 | CONSTRAINT fk_photo FOREIGN KEY ( photoid ) REFERENCES photos ( photoid ) ON DELETE CASCADE, 53 | CONSTRAINT fk_user FOREIGN KEY ( userid ) REFERENCES users ( userid ) ON DELETE CASCADE, 54 | CONSTRAINT fk_tag FOREIGN KEY ( tagid ) REFERENCES tags ( tagid ) ON DELETE CASCADE 55 | ); 56 | 57 | INSERT INTO users 58 | (name, userid) 59 | VALUES('Marc', '1'); 60 | 61 | INSERT INTO photos 62 | (url, userid, date, rating) 63 | VALUES('https://s.hdnux.com/photos/01/10/02/10/18883120/8/920x920.jpg', 1, 'today', 0); 64 | 65 | INSERT INTO tags 66 | (tag, userid) 67 | VALUES('Monster Trucks', 1); 68 | 69 | INSERT INTO phototags 70 | (userid, photoid, tagid) 71 | VALUES(1, 1, 1); 72 | -------------------------------------------------------------------------------- /server/controllers/oauthController.js: -------------------------------------------------------------------------------- 1 | const { google } = require("googleapis"); 2 | 3 | /* 4 | documentation that will explain everything below: https://www.npmjs.com/package/googleapis#oauth2-client 5 | */ 6 | 7 | const CLIENT_ID = process.env.CLIENT_ID; 8 | const CLIENT_SECRET = process.env.CLIENT_SECRET; 9 | const REDIRECT_URL = process.env.REDIRECT_URL; 10 | 11 | const oauth2Client = new google.auth.OAuth2( 12 | CLIENT_ID, 13 | CLIENT_SECRET, 14 | REDIRECT_URL 15 | ); 16 | 17 | const oauthController = {}; 18 | 19 | // =================================== // 20 | 21 | oauthController.getAuthURL = (req, res, next) => { 22 | 23 | // Asks permissions for these things: 24 | const scopes = [ 25 | "https://www.googleapis.com/auth/userinfo.email", 26 | "https://www.googleapis.com/auth/userinfo.profile", 27 | ]; 28 | 29 | // the link that we use to redirect to the consent page 30 | const url = oauth2Client.generateAuthUrl({ 31 | access_type: "offline", 32 | scope: scopes, 33 | response_type: "code", 34 | prompt: "consent", 35 | }); 36 | 37 | res.locals.url = url; 38 | return next(); 39 | }; 40 | 41 | // =================================== // 42 | 43 | oauthController.getAuthCode = async (req, res, next) => { 44 | const { tokens } = await oauth2Client.getToken(req.query.code); // Tokens contains access_token, refresh_token, scope, id-token 45 | oauth2Client.setCredentials(tokens); // Actually logs you in 46 | res.locals.token = tokens.id_token; // Store the id token for setting the cookie 47 | return next(); 48 | }; 49 | 50 | // =================================== // 51 | 52 | oauthController.setSSIDCookie = (req, res, next) => { 53 | res.cookie('SSID Cookie', res.locals.token, { httpOnly: true }); // set a cookie with the token ID 54 | return next(); 55 | }; 56 | 57 | // =================================== // 58 | 59 | oauthController.removeCookie = (req, res, next) => { 60 | res.clearCookie('SSID Cookie'); 61 | return next(); 62 | }; 63 | 64 | // =================================== // 65 | 66 | module.exports = oauthController; -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const express = require('express'); 3 | const app = express(); 4 | const cookieParser = require('cookie-parser'); 5 | const PORT = process.env.PORT || 3000; 6 | 7 | const path = require('path'); 8 | 9 | const wsServer = require("./wsServer") 10 | 11 | const imageRouter = require("./routes/images") 12 | const tagRouter = require("./routes/tags") 13 | const apiRouter = require("./routes/api") 14 | 15 | // JSON parser: 16 | app.use(express.json()); 17 | app.use(express.urlencoded({ 18 | extended: true 19 | })); 20 | app.use(cookieParser()); 21 | 22 | //*** WEBSOCKETS ****/ 23 | // I'm maintaining all active ws connections in this object 24 | const clients = {}; 25 | // I'm maintaining all active ws users in this object 26 | const users = {}; 27 | //install client and users to global middleware 28 | app.use((req, res, next) => { 29 | res.locals.clients = clients; 30 | res.locals.users = users; 31 | return next() 32 | }) 33 | 34 | // Webpack production 35 | if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') { 36 | // statically serve everything in the dist folder on the route 37 | app.use('/dist', express.static(path.resolve(process.cwd(), './dist'))); 38 | // serve index.html on the route '/' 39 | app.get('/', (req, res) => { 40 | return res.status(200).sendFile(path.resolve(process.cwd(), './client/src/index.html')); 41 | }); 42 | } 43 | 44 | // IMAGES ROUTER 45 | app.use('/images', imageRouter); 46 | 47 | // // TAGS ROUTER 48 | app.use('/tags', tagRouter); 49 | 50 | // API ROUTER 51 | app.use('/api', apiRouter); 52 | 53 | // catch-all endpoint handler 54 | app.use((req, res) => { 55 | return res.status(400).send('Page not found.') 56 | }); 57 | 58 | // global error handler 59 | app.use((err, req, res, next) => { 60 | const defaultErr = { 61 | log: 'Express error handler caught unkown middleware error!', 62 | status: 500, 63 | message: { 64 | err: 'An error occurred!' 65 | } 66 | }; 67 | const errorObj = Object.assign(defaultErr, err); 68 | console.log(errorObj.log); 69 | return res.status(errorObj.status).json(errorObj.message); 70 | }) 71 | 72 | // `server` is a vanilla Node.js HTTP server, so use 73 | // the same ws upgrade process described here: 74 | // https://www.npmjs.com/package/ws#multiple-servers-sharing-a-single-https-server 75 | const server = app.listen(PORT, () => { 76 | console.log('Listening on ' + PORT); 77 | }); 78 | 79 | //start web socket server 80 | wsServer(server, clients, users); 81 | 82 | module.exports = app; 83 | -------------------------------------------------------------------------------- /server/wsserver.js: -------------------------------------------------------------------------------- 1 | const webSocketServer = require('websocket').server; 2 | const db = require('./models/model'); 3 | const queries = require('./utils/queries'); 4 | const { 5 | sendMessage 6 | } = require('./utils/websockets') 7 | 8 | module.exports = (server, clients, users) => { 9 | 10 | const wsServer = new webSocketServer({ 11 | httpServer: server 12 | }); 13 | 14 | // Generates unique ID for every new connection 15 | const getUniqueID = () => { 16 | const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); 17 | return s4() + s4() + '-' + s4(); 18 | }; 19 | 20 | const typesDef = { 21 | GET_IMAGES: "getimages" 22 | } 23 | 24 | wsServer.on('request', function (request) { 25 | var userID = getUniqueID(); 26 | console.log((new Date()) + ' Recieved a new connection from origin ' + request.origin + '.'); 27 | // You can rewrite this part of the code to accept only the requests from allowed origin 28 | const connection = request.accept(null, request.origin); 29 | clients[userID] = connection; 30 | console.log('connected: ' + userID + ' in ' + Object.getOwnPropertyNames(clients)); 31 | connection.on('message', function (message) { 32 | 33 | if (message.type === 'utf8') { 34 | const dataFromClient = JSON.parse(message.utf8Data); 35 | console.log("message from client: ", userID, ". Message: ", dataFromClient) 36 | const json = { 37 | type: dataFromClient.type 38 | }; 39 | if (dataFromClient.type === typesDef.GET_IMAGES) { 40 | //hard-code userid until sessions set up 41 | const userid = 1; 42 | 43 | db.query(queries.getImages, [userid]) 44 | .then(photos => { 45 | db.query(queries.getAllImageTags, [userid]) 46 | .then(tags => { 47 | const tagObj = {}; 48 | 49 | tags.rows.forEach(tag => { 50 | tagObj[tag.photoid] ? tagObj[tag.photoid].push(tag.tag) : tagObj[tag.photoid] = [tag.tag] 51 | }) 52 | 53 | json.data = photos.rows.map(photo => { 54 | photo.tags = tagObj[photo.photoid] || [] 55 | return photo; 56 | }) 57 | sendMessage(json, clients); 58 | 59 | }) 60 | }) 61 | } 62 | } 63 | }); 64 | // user disconnected 65 | connection.on('close', function (connection) { 66 | console.log((new Date()) + " Peer " + userID + " disconnected."); 67 | delete clients[userID]; 68 | delete users[userID]; 69 | }); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /client/src/components/Photo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as actions from '../actions/actions'; 4 | import { Dropdown } from 'react-bootstrap'; 5 | 6 | const mapStateToProps = state => ({ 7 | tags: state.photos.tags, 8 | }); 9 | 10 | const mapDispatchToProps = dispatch => ({ 11 | handleDeletePhoto: (photoid) => dispatch(actions.deletePhoto(photoid)), 12 | handleAddTagPhoto: (photoid, tagObj) => dispatch(actions.addTagPhoto(photoid, tagObj)), 13 | }); 14 | 15 | const deleteFromServer = (id) => { 16 | console.log('DELETE') 17 | fetch(`http://localhost:3000/images/${id}`, { 18 | method: 'DELETE', 19 | // headers: { 20 | // 'Access-Control-Allow-Origin': '*' 21 | // }, 22 | // body: JSON.stringify({ 23 | // url: imageUrl 24 | // }) 25 | }) 26 | .then(res => res.json()) 27 | .catch(err => console.log('Error: ', err)) 28 | } 29 | 30 | const addTagToPhoto = (photoid, tagid) => { 31 | console.log('PUT') 32 | fetch(`http://localhost:3000/tags/${tagid}?photoid=${photoid}`, { 33 | method: 'PUT', 34 | }) 35 | .then(res => res.json()) 36 | .catch(err => console.log('Error: ', err)) 37 | } 38 | 39 | const Photo = (props) => { 40 | const { url, photoid, photoTags } = props; 41 | 42 | const dropTagList = props.tags.map((tag, index) => { 43 | 44 | return ( 45 | 46 | 53 | 54 | ) 55 | }); 56 | 57 | return ( 58 |
59 | 60 | {/*
*/} 61 |
62 | <> 63 | 64 | 65 | Tag 66 | 67 | 68 | 69 | {dropTagList} 70 | 71 | 72 | 73 | 74 | 82 |
83 | {/*
*/} 84 | 85 | 86 | 87 |
88 | ) 89 | } 90 | 91 | export default connect(mapStateToProps, mapDispatchToProps)(Photo); 92 | 93 | -------------------------------------------------------------------------------- /server/controllers/tagController.js: -------------------------------------------------------------------------------- 1 | const db = require('../models/model'); 2 | const queries = require('../utils/queries'); 3 | 4 | const tagController = {}; 5 | 6 | //SENDS LIST OF TAGS BACK TO CLIENT 7 | tagController.getTags = (req, res, next) => { 8 | 9 | //dummy id until cookies are set up 10 | const userid = 1; 11 | 12 | db.query(queries.getTags, [userid]) 13 | .then(tags => { 14 | res.locals.data = tags.rows; 15 | return next(); 16 | }) 17 | .catch(err => { 18 | return next({ 19 | log: `Error occurred with queries.getTags: ${err}`, 20 | message: { 21 | err: 'An error occured with SQL when getting tags.' 22 | }, 23 | }); 24 | }) 25 | } 26 | 27 | //ADDS A NEW TAG TO THE DATABASE 28 | tagController.addTag = (req, res, next) => { 29 | 30 | //deconstruct tag name from body 31 | const { 32 | tag 33 | } = req.body; 34 | 35 | //dummy id until cookies are set up 36 | const userid = 1; 37 | 38 | db.query(queries.addTag, [tag, userid]) 39 | .then(data => { 40 | res.locals.data = data.rows[0]; 41 | return next(); 42 | }) 43 | .catch(err => { 44 | return next({ 45 | log: `Error occurred with queries.addTag: ${err}`, 46 | message: { 47 | err: 'An error occured with SQL when creating a new tag.' 48 | }, 49 | }); 50 | }) 51 | } 52 | 53 | //ADDS A TAG TO THE GIVEN PHOTOID 54 | tagController.updateTag = (req, res, next) => { 55 | 56 | //dummy id until cookies are set up 57 | const userid = 1; 58 | 59 | //deconstruct tagid from params 60 | const { 61 | tagid 62 | } = req.params; 63 | 64 | //deconstruct photoid from query 65 | const { 66 | photoid 67 | } = req.query; 68 | 69 | db.query(queries.updateTag, [userid, photoid, tagid]) 70 | .then(data => { 71 | return next(); 72 | }) 73 | .catch(err => { 74 | return next({ 75 | log: `Error occurred with queries.updateTag: ${err}`, 76 | message: { 77 | err: 'An error occured with SQL when updating an image with a tag.' 78 | }, 79 | }); 80 | }) 81 | } 82 | 83 | //REMOVES A TAG FROM THE GIVEN PHOTOID 84 | tagController.deleteTag = (req, res, next) => { 85 | 86 | //dummy id until cookies are set up 87 | const userid = 1; 88 | 89 | //deconstruct tagid from params 90 | const { 91 | tagid 92 | } = req.params; 93 | 94 | //deconstruct photoid from query 95 | const { 96 | photoid 97 | } = req.query; 98 | 99 | db.query(queries.deleteTag, [userid, photoid, tagid]) 100 | .then(data => { 101 | return next(); 102 | }) 103 | .catch(err => { 104 | return next({ 105 | log: `Error occurred with queries.deleteTag: ${err}`, 106 | message: { 107 | err: 'An error occured with SQL when removing a tag from an image.' 108 | }, 109 | }); 110 | }) 111 | } 112 | 113 | module.exports = tagController; 114 | -------------------------------------------------------------------------------- /client/src/containers/SideBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as actions from '../actions/actions'; 4 | import { 5 | Nav, 6 | NavDropdown, 7 | Accordion, 8 | Card, 9 | Button, 10 | InputGroup, 11 | FormControl 12 | } from 'react-bootstrap'; 13 | 14 | const mapStateToProps = state => ({ 15 | tags: state.photos.tags, 16 | inputTag: state.photos.inputTag, 17 | }); 18 | 19 | const mapDispatchToProps = dispatch => ({ 20 | handleGetTags: (tags) => dispatch(actions.getTags(tags)), 21 | handleTagInput: (input) => dispatch(actions.inputTag(input)), 22 | handleAddTagType: (tagObj) => dispatch(actions.addTagType(tagObj)), 23 | handleAddTag: (tagObj) => dispatch(actions.addTag(tagObj)), 24 | handleFilterByTag: (tagName) => dispatch(actions.filterByTag(tagName)), // insert parameters 25 | }); 26 | 27 | const SideBar = (props) => { 28 | useEffect(() => { 29 | fetch('/tags') 30 | .then(res => res.json()) 31 | .then(res => props.handleGetTags(res)) 32 | .catch(err => console.log(err)) 33 | }, []); 34 | 35 | const handleTagClick = () => { 36 | const tagName = props.inputTag.trim(); 37 | fetch('http://localhost:3000/tags', { 38 | method: 'POST', 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | }, 42 | body: JSON.stringify({ 43 | tag: tagName 44 | }) 45 | }) 46 | .then(res => res.json()) 47 | .then(res => { 48 | console.log('res', res) // {tagid: 3} 49 | props.handleAddTagType({ 50 | tagid: res.tagid, 51 | tag: tagName, 52 | // userid: res.userid 53 | }); 54 | }) 55 | .catch(err => console.log('Error: ', err)) 56 | }; 57 | 58 | const tagList = props.tags.map((tag, index) => { 59 | return ( 60 | 65 | ) 66 | }); 67 | 68 | return ( 69 |
70 | 71 | 72 | 73 | 74 | Tags 75 | 76 | 77 | 78 | 79 |
80 | {tagList} 81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | 89 | props.handleTagInput(e.target.value)} /> 90 | 91 | {/* */} 92 | 93 | 94 |
95 |
96 | ) 97 | } 98 | 99 | export default connect(mapStateToProps, mapDispatchToProps)(SideBar); -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | 3 | @import url('https://fonts.googleapis.com/css2?family=Sansita+Swashed:wght@600&display=swap'); 4 | 5 | 6 | * { 7 | margin: 0 !important; 8 | padding: 0 !important; 9 | box-sizing: border-box !important; 10 | } 11 | 12 | .logo { 13 | width: 200px; 14 | height: 200px; 15 | object-fit: cover; 16 | 17 | } 18 | 19 | #header { 20 | background: #F18C8E; 21 | } 22 | 23 | .header-container { 24 | display: flex; 25 | justify-content: space-between; 26 | height: 200px; 27 | background-image: linear-gradient(140deg, #06A0DC 0%, #DE726A 50%, #06A0DC 75%); 28 | 29 | } 30 | 31 | .header-container > h1 { 32 | color: white; 33 | font-family: 'Sansita Swashed', cursive; 34 | font-size: 55px; 35 | } 36 | 37 | .sidebar-container { 38 | background-image: linear-gradient(140deg, white 0%, #DE726A 50%, white 75%); 39 | } 40 | 41 | #search-bar { 42 | background: #F18C8E; 43 | } 44 | 45 | .sidebar { 46 | background: #568EA6; 47 | // position: fixed; 48 | // top: 0; 49 | // bottom: 0; 50 | // left: 0; 51 | // min-height: 100vh !important; 52 | // z-index: 100; 53 | // padding: 48px 0 0; 54 | // box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 55 | } 56 | 57 | .button-tag { 58 | display: block !important; 59 | text-align: left; 60 | border: none; 61 | width: 100%; 62 | outline: none !important; 63 | } 64 | 65 | .button-tag:hover { 66 | background: grey; 67 | } 68 | 69 | .highlight-tag { 70 | display: block !important; 71 | background: #8799ff !important; 72 | text-align: left; 73 | border: none; 74 | width: 100%; 75 | outline: none !important; 76 | } 77 | 78 | // #sidebar-wrapper{ 79 | // min-height: 100vh !important; 80 | // width: 100vw; 81 | // margin-left: -1rem; 82 | // -webkit-transition: margin .25s ease-out; 83 | // -moz-transition: margin .25s ease-out; 84 | // -o-transition: margin .25s ease-out; 85 | // transition: margin .25s ease-out; 86 | // } 87 | // #sidebar-wrapper .sidebar-heading { 88 | // padding: 0.875rem 1.25rem; 89 | // font-size: 1.2rem; 90 | // } 91 | 92 | // #page-content-wrapper { 93 | // min-width: 0; 94 | // width: 100%; 95 | // } 96 | 97 | //====Original side-bar====// 98 | #side-bar { 99 | background: #568EA6; 100 | } 101 | 102 | #main-gallery { 103 | background: #dedede; 104 | } 105 | 106 | .photosAll { 107 | display: flex; 108 | // justify-content: space-around; 109 | flex-wrap: wrap; 110 | } 111 | 112 | .photo-container { 113 | margin: 30px !important; 114 | } 115 | 116 | .photo-container * img { 117 | width: 250px; 118 | height: 250px; 119 | object-fit: cover; 120 | border-radius: 4px; 121 | } 122 | 123 | .photo-options { 124 | background: #6c757d; 125 | // opacity: .60; 126 | display: flex; 127 | justify-content: space-between; 128 | align-items: center; 129 | position: relative; 130 | top: 26px; 131 | visibility: hidden; 132 | } 133 | 134 | .photo-container:hover > .photo-options { 135 | visibility: visible; 136 | } 137 | 138 | .photo-delete-button { 139 | color: #ffffff; 140 | background: #6c757d; 141 | border: none; 142 | // border-radius: 4px; 143 | outline: none; 144 | width: 20%; 145 | height: 9%; 146 | } 147 | 148 | #footer { 149 | background: #305F72; 150 | } 151 | -------------------------------------------------------------------------------- /server/controllers/imageController.js: -------------------------------------------------------------------------------- 1 | const db = require('../models/model'); 2 | const queries = require('../utils/queries'); 3 | 4 | const imageController = {}; 5 | 6 | //GETS ALL USERS IMAGES FROM THE DATABASE 7 | imageController.getImages = (req, res, next) => { 8 | //hard-code userid until sessions set up 9 | const userid = 1; 10 | 11 | db.query(queries.getImages, [userid]) 12 | .then(photos => { 13 | db.query(queries.getAllImageTags, [userid]) 14 | .then(tags => { 15 | const tagObj = {}; 16 | 17 | tags.rows.forEach(tag => { 18 | tagObj[tag.photoid] ? tagObj[tag.photoid].push(tag.tag) : tagObj[tag.photoid] = [tag.tag] 19 | }) 20 | 21 | res.locals.data = photos.rows.map(photo => { 22 | photo.tags = tagObj[photo.photoid] || [] 23 | return photo; 24 | }) 25 | return next(); 26 | }) 27 | }) 28 | .catch(err => { 29 | return next({ 30 | log: `Error occurred with queries.getImages: ${err}`, 31 | message: { 32 | err: 'An error occured with SQL when getting images.' 33 | }, 34 | }); 35 | }) 36 | } 37 | 38 | //ADDS AN IMAGE TO THE DATABASE 39 | imageController.addImage = (req, res, next) => { 40 | 41 | //deconstruct url from body 42 | const { 43 | url 44 | } = req.body; 45 | 46 | //dummy id until cookies are set up 47 | const userid = 1; 48 | 49 | // define new date/time 50 | const now = new Date(); 51 | const date = `${now.toDateString()}-${now.toTimeString().split(' ')[0]}`; 52 | 53 | //default rating of 0 54 | const rating = 0; 55 | 56 | db.query(queries.addImage, [url, userid, date, rating]) 57 | .then(data => { 58 | const { 59 | photoid 60 | } = data.rows[0]; 61 | res.locals.data = data.rows[0]; 62 | res.locals.websocket = { 63 | userid, 64 | url, 65 | date, 66 | photoid, 67 | rating, 68 | tags: [] 69 | } 70 | return next(); 71 | }) 72 | .catch(err => { 73 | return next({ 74 | log: `Error occurred with queries.addImage: ${err}`, 75 | message: { 76 | err: 'An error occured with SQL when saving an image.' 77 | }, 78 | }); 79 | }) 80 | } 81 | 82 | //ADDS RATING TO THE IMAGE 83 | imageController.updateImage = (req, res, next) => { 84 | 85 | //deconstruct url from params 86 | const { 87 | photoid 88 | } = req.params; 89 | 90 | //deconstruct url from query 91 | const { 92 | rating 93 | } = req.query; 94 | 95 | db.query(queries.updateImage, [photoid, rating]) 96 | .then(data => { 97 | return next(); 98 | }) 99 | .catch(err => { 100 | return next({ 101 | log: `Error occurred with queries.updateImage: ${err}`, 102 | message: { 103 | err: 'An error occured with SQL when updating an image.' 104 | }, 105 | }); 106 | }) 107 | } 108 | 109 | //REMOVES USERS IMAGE 110 | imageController.deleteImage = (req, res, next) => { 111 | 112 | //deconstruct url from params 113 | const { 114 | photoid 115 | } = req.params; 116 | 117 | db.query(queries.deleteImage, [photoid]) 118 | .then(data => { 119 | return next(); 120 | }) 121 | .catch(err => { 122 | return next({ 123 | log: `Error occurred with queries.deleteImage: ${err}`, 124 | message: { 125 | err: 'An error occured with SQL when deleting an image.' 126 | }, 127 | }); 128 | }) 129 | } 130 | 131 | module.exports = imageController; 132 | -------------------------------------------------------------------------------- /client/src/reducers/photosReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/actionTypes'; 2 | 3 | const initialState = { 4 | photos: [], 5 | filteredPhotos: [], 6 | tags: [], 7 | inputTag: '', 8 | }; 9 | 10 | const photosReducer = (state = initialState, action) => { 11 | let photos; 12 | let photosClone; 13 | let updatedPhotos; 14 | let tagsClone; 15 | let updatedTags; 16 | let updatedTagName; 17 | let filter; 18 | let filteredPhotos; 19 | 20 | switch (action.type) { 21 | case types.GET_PHOTOS: 22 | photos = action.payload; 23 | 24 | return { 25 | ...state, photos 26 | }; 27 | 28 | case types.ADD_PHOTO: 29 | photos = [...state.photos]; 30 | photos.push(action.payload); 31 | 32 | return { 33 | ...state, photos 34 | }; 35 | 36 | case types.DELETE_PHOTO: 37 | photosClone = JSON.parse(JSON.stringify(state.photos)); 38 | 39 | updatedPhotos = photosClone.filter( 40 | (photo) => { 41 | console.log(photo.photoid, action.payload) 42 | return photo.photoid !== action.payload 43 | }); 44 | console.log('updated photos', updatedPhotos) 45 | 46 | return { 47 | ...state, photos: updatedPhotos 48 | }; 49 | 50 | case types.ADD_RATING: 51 | photosClone = JSON.parse(JSON.stringify(state.photos)); 52 | 53 | updatedPhotos = photosClone.map((photo) => { 54 | if (photo.photoid === action.payload.photoId) { 55 | photo.rating = action.payload.rating; 56 | } 57 | }); 58 | 59 | return { 60 | ...state, photos: updatedPhotos 61 | }; 62 | 63 | case types.GET_TAGS: 64 | updatedTags = action.payload; 65 | 66 | return { 67 | ...state, tags: updatedTags 68 | }; 69 | 70 | case types.INPUT_TAG: 71 | updatedTagName = action.payload; 72 | 73 | return { ...state, inputTag: updatedTagName }; 74 | 75 | case types.ADD_TAG_TYPE: 76 | // Note: Duplicate tags already handled by server on submit 77 | 78 | tagsClone = JSON.parse(JSON.stringify(state.tags)); 79 | tagsClone.push(action.payload); 80 | 81 | return { ...state, tags: tagsClone }; 82 | 83 | case types.ADD_TAG_PHOTO: 84 | photosClone = JSON.parse(JSON.stringify(state.photos)); 85 | 86 | updatedPhotos = photosClone.map((photo) => { 87 | if (photo.photoid === action.payload.photoId) { 88 | // Add new tag object to the given photo's tags array 89 | photo.tags.push(action.payload.tagObj.tag); 90 | } 91 | return photo; 92 | }); 93 | 94 | return { ...state, photos: updatedPhotos }; 95 | 96 | case types.FILTER_BY_TAG: 97 | photosClone = JSON.parse(JSON.stringify(state.photos)); 98 | filteredPhotos = photosClone.filter(photo => photo.tags.includes(action.payload)) 99 | 100 | return { ...state, filteredPhotos }; 101 | 102 | case types.REMOVE_TAG: 103 | photosClone = JSON.parse(JSON.stringify(state.photos)); 104 | tagsClone = JSON.parse(JSON.stringify(state.tags)); 105 | 106 | updatedPhotos = photosClone.map((photo) => { 107 | if (photo.photoid === action.payload.photoId) { 108 | // Remove tag object from the given photo's tags array 109 | photo.tags = photo.tags.filter( 110 | (tag) => tag.tagid !== action.payload.trashTag.tagID 111 | ); 112 | // Add new tag object to available tags array 113 | updatedTags = tagsClone.filter( 114 | (tag) => tag.tagid !== action.payload.trashTag.tagID 115 | ); 116 | } 117 | }); 118 | 119 | return { 120 | ...state, photos: updatedPhotos, tags: updatedTags 121 | }; 122 | 123 | default: 124 | return state; 125 | } 126 | }; 127 | 128 | export default photosReducer; 129 | -------------------------------------------------------------------------------- /__tests__/server.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const server = 'http://localhost:3000'; 3 | 4 | /** 5 | * Read the docs! https://www.npmjs.com/package/supertest 6 | */ 7 | describe('Route integration', () => { 8 | describe('/', () => { 9 | describe('GET', () => { 10 | // Note that we return the evaluation of `request` here! It evaluates to 11 | // a promise, so Jest knows not to say this test passes until that 12 | // promise resolves. See https://jestjs.io/docs/en/asynchronous 13 | it('responds with 200 status and text/html content type', () => { 14 | return request(server) 15 | .get('/') 16 | .expect('Content-Type', /text\/html/) 17 | .expect(200); 18 | }); 19 | it('responds with 400 status when the route is nonsese', () => { 20 | return request(server) 21 | .get('/aklsjdfljasdfoiasf9jiaf') 22 | .expect(400); 23 | }); 24 | }); 25 | }); 26 | 27 | describe('/images', () => { 28 | let photoid; 29 | describe('GET', () => { 30 | it('responds with 200 status and application/json content type', () => { 31 | return request(server) 32 | .get('/images') 33 | .set('Accept', 'application/json') 34 | .expect('Content-Type', /json/) 35 | .expect(200); 36 | }); 37 | 38 | it('returns a list of images in the body of response', () => { 39 | const imageList = [{ 40 | "date": "today", 41 | "photoid": 1, 42 | "rating": 0, 43 | "tags": ["Monster Trucks"], 44 | "url": "https://s.hdnux.com/photos/01/10/02/10/18883120/8/920x920.jpg", 45 | "userid": "1" 46 | }]; 47 | return request(server) 48 | .get('/images') 49 | .expect(200) 50 | .then(response => { 51 | expect([response.body[0]]).toEqual(imageList) 52 | }) 53 | }); 54 | }); 55 | describe("POST", () => { 56 | it('responds with 200 status and application/json content type with photoid in body', () => { 57 | return request(server) 58 | .post('/images') 59 | .send({ 60 | url: "https://www.english-efl.com/wp-content/uploads/2019/12/test.jpg" 61 | }) 62 | .set('Accept', 'application/json') 63 | .expect('Content-Type', /json/) 64 | .expect(200) 65 | .then(response => { 66 | expect(response.body).toEqual(expect.objectContaining({ 67 | photoid: expect.any(Number) 68 | })) 69 | photoid = response.body.photoid; 70 | }) 71 | 72 | }); 73 | }); 74 | describe("PUT", () => { 75 | it('responds with 200 status and application/json content type', () => { 76 | return request(server) 77 | .put(`/images/${photoid}?rating=4`) 78 | .set('Accept', 'application/json') 79 | .expect('Content-Type', /json/) 80 | .expect(200) 81 | 82 | }); 83 | }); 84 | describe("DELETE", () => { 85 | it('responds with 200 status and application/json content type', () => { 86 | return request(server) 87 | .delete(`/images/${photoid}`) 88 | .set('Accept', 'application/json') 89 | .expect('Content-Type', /json/) 90 | .expect(200) 91 | }); 92 | }); 93 | }); 94 | describe('/tags', () => { 95 | let tagid; 96 | describe('GET', () => { 97 | it('responds with 200 status and application/json content type', () => { 98 | return request(server) 99 | .get('/tags') 100 | .set('Accept', 'application/json') 101 | .expect('Content-Type', /json/) 102 | .expect(200); 103 | }); 104 | 105 | it('returns a list of images in the body of response', () => { 106 | const tagList = [{ 107 | "tagid": 1, 108 | "tag": "Monster Trucks" 109 | }]; 110 | return request(server) 111 | .get('/tags') 112 | .expect(200) 113 | .then(response => { 114 | expect([response.body[0]]).toEqual(tagList) 115 | }) 116 | }); 117 | }); 118 | describe("POST", () => { 119 | it('responds with 200 status and application/json content type with photoid in body', () => { 120 | return request(server) 121 | .post('/tags') 122 | .send({ 123 | tag: "Testing" 124 | }) 125 | .set('Accept', 'application/json') 126 | .expect('Content-Type', /json/) 127 | .expect(200) 128 | .then(response => { 129 | expect(response.body).toEqual(expect.objectContaining({ 130 | tagid: expect.any(Number) 131 | })) 132 | tagid = response.body.tagid; 133 | }) 134 | 135 | }); 136 | }); 137 | describe("PUT", () => { 138 | it('responds with 200 status and application/json content type', () => { 139 | return request(server) 140 | .put(`/images/${tagid}?photoid=1`) 141 | .set('Accept', 'application/json') 142 | .expect('Content-Type', /json/) 143 | .expect(200) 144 | }); 145 | }); 146 | describe("DELETE", () => { 147 | it('responds with 200 status and application/json content type', () => { 148 | return request(server) 149 | .delete(`/images/${tagid}`) 150 | .set('Accept', 'application/json') 151 | .expect('Content-Type', /json/) 152 | .expect(200) 153 | }); 154 | }); 155 | }); 156 | }); 157 | --------------------------------------------------------------------------------