├── 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 | Search Bar
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 |
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 |
--------------------------------------------------------------------------------