├── .circleci
└── config.yml
├── .czrc
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── PULL_REQUEST_TEMPLATE.md
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── LICENSE
├── Procfile
├── README.md
├── client
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── .prettierrc
├── index.html
├── jest.config.js
├── package-lock.json
├── package.json
├── src
│ ├── App.tsx
│ ├── MainRouter.tsx
│ ├── __tests__
│ │ └── App.test.tsx
│ ├── assets
│ │ ├── 404.svg
│ │ ├── bg.svg
│ │ ├── customer.svg
│ │ ├── customer1.webp
│ │ ├── flow.svg
│ │ ├── flow.webp
│ │ ├── gallery.svg
│ │ ├── home.jpg
│ │ ├── home.svg
│ │ ├── home.webp
│ │ ├── lock.webp
│ │ ├── login-bg.svg
│ │ ├── logo.png
│ │ ├── mobile-in-hand.webp
│ │ └── signup.webp
│ ├── components
│ │ ├── albums
│ │ │ ├── Album.tsx
│ │ │ ├── AlbumDeleteModal.tsx
│ │ │ ├── AlbumOptions.tsx
│ │ │ ├── AlbumQuickEditModal.tsx
│ │ │ ├── AlbumShareModal.tsx
│ │ │ ├── AlbumThumbnails.tsx
│ │ │ ├── Albums.tsx
│ │ │ ├── ClientSideView.tsx
│ │ │ ├── Upload.tsx
│ │ │ └── api-albums.ts
│ │ ├── auth
│ │ │ ├── EmailConfirm.tsx
│ │ │ ├── Login.tsx
│ │ │ ├── PrivateRoute.tsx
│ │ │ ├── ResetPassword.tsx
│ │ │ ├── ResetPasswordRequest.tsx
│ │ │ ├── SignUp.tsx
│ │ │ ├── api-auth.ts
│ │ │ └── auth-helper.ts
│ │ ├── contexts
│ │ │ ├── albumContext.tsx
│ │ │ └── userContext.tsx
│ │ ├── core
│ │ │ ├── ErrorPage.tsx
│ │ │ ├── Features.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Home.tsx
│ │ │ ├── LayOut.tsx
│ │ │ ├── Loader.tsx
│ │ │ ├── NavBar.tsx
│ │ │ └── ThemeToggleButton.tsx
│ │ ├── hooks
│ │ │ └── useTimeOut.ts
│ │ └── user
│ │ │ ├── Dashboard.tsx
│ │ │ ├── Profile.tsx
│ │ │ ├── api-photos.ts
│ │ │ └── api-user.ts
│ ├── favicon.svg
│ ├── logo.svg
│ ├── main.tsx
│ ├── theme.ts
│ ├── utils
│ │ ├── general.ts
│ │ ├── interfaces.ts
│ │ └── misc.ts
│ └── vite-env.d.ts
├── tsconfig.json
├── vercel.json
└── vite.config.ts
├── commitlint.config.js
├── jest.config.json
├── nodemon.json
├── package.json
├── public
└── images
│ └── .gitkeep
└── server
├── __tests__
└── albums.test.js
├── app.js
├── controllers
├── album.controller.js
├── auth.controller.js
├── photo.controller.js
└── user.controller.js
├── docs
└── swagger.yaml
├── helpers
├── dbErrorHandler.js
└── sendMail.js
├── middlewares
└── api.key.controller.js
├── models
├── album.model.js
├── photo.model.js
└── user.model.js
├── routes
├── auth.routes.js
└── user.routes.js
└── server.js
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | orbs:
4 | node: circleci/node@5.1.0
5 |
6 | jobs:
7 | linting-frontend:
8 | executor: node/default
9 | steps:
10 | - checkout
11 | - node/install-packages
12 | - run:
13 | name: Frontend Code Linting
14 | command: |
15 | cd client
16 | yarn install
17 | yarn pretest
18 |
19 | # testing-frontend:
20 | # executor: node/default
21 | # steps:
22 | # - checkout
23 | # - node/install-packages
24 | # - run:
25 | # name: Frontend Testing
26 | # command: |
27 | # cd client
28 | # yarn install
29 | # yarn test-ci
30 |
31 | linting-backend:
32 | executor: node/default
33 | steps:
34 | - checkout
35 | - node/install-packages
36 | - run:
37 | name: Backend Code Linting
38 | command: yarn format
39 |
40 | testing-backend:
41 | executor: node/default
42 | steps:
43 | - checkout
44 | - node/install-packages
45 | - run:
46 | name: Backend Code Testing
47 | command: yarn test
48 |
49 | # testing-backend:
50 | # executor: node/default
51 | # steps:
52 | # - checkout
53 | # - node/install-packages
54 | # - run:
55 | # name: Backend Testing
56 | # command: yarn test
57 |
58 | workflows:
59 | version: 2
60 | lint_test:
61 | jobs:
62 | - linting-frontend:
63 | context: org-global
64 | # - testing-frontend:
65 | # context: org-global
66 | - linting-backend:
67 | context: org-global
68 | - testing-backend:
69 | context: org-global
70 |
--------------------------------------------------------------------------------
/.czrc:
--------------------------------------------------------------------------------
1 | {
2 | "path": "cz-conventional-changelog"
3 | }
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 |
16 |
17 | 1. Go to '...'
18 | 2. Click on '....'
19 | 3. Scroll down to '....'
20 | 4. See error
21 |
22 | **Expected behavior**
23 | A clear and concise description of what you expected to happen.
24 |
25 | **Screenshots**
26 | If applicable, add screenshots to help explain your problem.
27 |
28 | **Desktop (please complete the following information):**
29 |
30 | - OS: [e.g. iOS]
31 | - Browser [e.g. chrome, safari]
32 | - Version [e.g. 22]
33 |
34 | **Smartphone (please complete the following information):**
35 |
36 | - Device: [e.g. iPhone6]
37 | - OS: [e.g. iOS8.1]
38 | - Browser [e.g. stock browser, safari]
39 | - Version [e.g. 22]
40 |
41 | **Additional context**
42 | Add any other context about the problem here.
43 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | ## Pull Request
2 |
3 | ### Type
4 |
5 | - [ ] Bug Fix
6 | - [ ] Feature Addition
7 |
8 | ### Description
9 |
10 | A brief description of the pull request.
11 |
12 | ### Issue
13 |
14 | Closes #[Issue Number]
15 |
16 | ### Changes Made
17 |
18 | A concise overview of the changes made in this pull request.
19 |
20 | ### Screenshots
21 |
22 | If applicable, provide screenshots showcasing the changes.
23 |
24 | ### Checklist
25 |
26 | - [ ] Code follows project conventions
27 | - [ ] Tests (if applicable) have been added and passed
28 | - [ ] Documentation has been updated (if applicable)
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | .vscode
4 | .env
5 | yarn.lock
6 | public
7 | coverage
8 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn commitlint --edit $1
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn format
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | lts/*
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | # Ignore artifacts:
3 | build/
4 | coverage/
5 |
6 | vite-env.d.ts
7 | vite.config.ts
8 | jest.config.js
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "semi": true
6 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 [Siaw A. Nicholas]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web:npm start
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://dl.circleci.com/status-badge/redirect/gh/ayequill/ShutterSync/tree/main)
2 |
3 | [](https://choosealicense.com/licenses/mit/)
4 | 
5 | 
6 | 
7 | 
8 | 
9 | 
10 | 
11 | 
12 |
13 |
14 |

15 |

16 |
17 |
18 | # ShutterSync
19 |
20 | ShutterSync aims to address the need for professional photographers to efficiently share and collaborate on their work with clients, enhancing client engagement and project management.
21 |
22 | ## Documentation
23 |
24 | [Documentation](https://docs.shuttersync.live)
25 |
26 | [Documentation & Testing](https://api.shuttersync.live/api-docs)
27 | You will need an API_KEY for production server.
28 |
29 | ## Features
30 |
31 | - Lightweight image assets for display and you get to keep your original files
32 | - Light/dark mode toggle
33 | - Responsive design
34 | - User authentication
35 | - User authorization
36 | - User profile management
37 | - Album management
38 | - Photo management
39 | - Photo sharing
40 |
41 | ## Tech Stack
42 |
43 | **Client:** React, TypeScript, Chakra UI, Framermotion
44 |
45 | **Server:** Node, Express, JavaScript, MongoDB, Cloudinary, Multer
46 |
47 | **Testing:** Jest, React Testing Library, Supertest
48 |
49 | **Deployment:** Heroku, Google Cloud Platform, Vercel
50 |
51 | ## Installation
52 |
53 | Run locally. Git clone first.
54 |
55 | ```bash
56 | npm install ShutterSync
57 | cd ShutterSync
58 | ```
59 |
60 | You will need a Mongo DB setup if you want to run it with your DB.
61 |
62 | #### Environment Variables
63 |
64 | To run this project, you will need to add the following environment variables to your .env file
65 |
66 | `API_KEY`
67 | `DB_URI`
68 | `DB_URI`
69 | `PORT`
70 | `CLOUDINARY_API_KEY`
71 | `MAIL_HOST`
72 | `MAIL_PORT`
73 | `MAIL_USER`
74 | `MAIL_PWD`
75 | `MAIL_DOMAIN`
76 | `GOOGLE_PROJECT_ID`
77 | `GOOGLE_PRIVATE_KEY_ID`
78 | `GOOGLE_PRIVATE_KEY`
79 | `GOOGLE_CLIENT_EMAIL`
80 |
81 | ### CI Tests
82 | [](https://app.circleci.com/insights/github/ayequill/ShutterSync/workflows/lint_test/overview?branch=main&reporting-window=last-30-days&insights-snapshot=true)
83 |
84 | ## API References
85 |
86 | ### Include a 'x-api-key' header with your API_KEY in all requests
87 |
88 | #### Sign up
89 |
90 | ```http
91 | POST /api/auth/signup
92 | ```
93 |
94 | | Parameter | Type | Description |
95 | | :-------- | :------- | :------------------------- |
96 | | `email` | `string` | **Required**. Your email |
97 | | `name` | `string` | **Required**. Your name |
98 | | `password`| `string` | **Required**. Your password|
99 |
100 | #### Sign in
101 |
102 | ```http
103 | POST /api/auth/signin
104 | ```
105 |
106 | | Parameter | Type | Description |
107 | | :-------- | :------- | :------------------------- |
108 | | `email` | `string` | **Required**. Your email |
109 | | `password`| `string` | **Required**. Your password|
110 |
111 | #### Sign out
112 |
113 | ```http
114 | GET /api/auth/signout
115 | ```
116 |
117 | #### Get user
118 |
119 | ```http
120 | GET /api/users/${id}
121 | ```
122 |
123 | | Parameter | Type | Description |
124 | | :-------- | :------- | :-------------------------------- |
125 | | `id` | `string` | **Required**. Id of user to fetch |
126 |
127 | #### Update user
128 |
129 | ```http
130 | PUT /api/users/${id}
131 | ```
132 |
133 | | Parameter | Type | Description |
134 | | :-------- | :------- | :-------------------------------- |
135 | | `id` | `string` | **Required**. Id of user to update|
136 | | `name` | `string` | **Required**. Your name |
137 | | `email` | `string` | **Required**. Your email |
138 | | `password`| `string` | **Required**. Your password |
139 |
140 | #### Delete user
141 |
142 | ```http
143 | DELETE /api/users/${id}
144 | ```
145 |
146 | | Parameter | Type | Description |
147 | | :-------- | :------- | :-------------------------------- |
148 | | `id` | `string` | **Required**. Id of user to delete|
149 |
150 | #### Create an album
151 |
152 | ```http
153 | POST /api/users/${userid}/albums
154 | ```
155 |
156 | | Parameter | Type | Description |
157 | | :-------- | :------- | :-------------------------------- |
158 | | `userid` | `string` | **Required**. Id of user |
159 | | `name` | `string` | **Required**. Name of album |
160 |
161 | #### Get an album
162 |
163 | ```http
164 | GET /api/users/${userid}/albums/${albumid}
165 | ```
166 |
167 | | Parameter | Type | Description |
168 | | :-------- | :------- | :-------------------------------- |
169 | | `userid` | `string` | **Required**. Id of user |
170 | | `albumid` | `string` | **Required**. Id of album |
171 |
172 | #### Update an album
173 |
174 | ```http
175 | PUT /api/users/${userid}/albums/${albumid}
176 | ```
177 |
178 | | Parameter | Type | Description |
179 | | :-------- | :------- | :-------------------------------- |
180 | | `userid` | `string` | **Required**. Id of user |
181 | | `albumid` | `string` | **Required**. Id of album |
182 | | `name` | `string` | **Required**. Name of album |
183 |
184 | #### Delete an album
185 |
186 | ```http
187 | DELETE /api/users/${userid}/albums/${albumid}
188 | ```
189 |
190 | | Parameter | Type | Description |
191 | | :-------- | :------- | :-------------------------------- |
192 | | `userid` | `string` | **Required**. Id of user |
193 | | `albumid` | `string` | **Required**. Id of album |
194 |
195 | #### Get all albums
196 |
197 | ```http
198 | GET /api/users/${userid}/albums
199 | ```
200 |
201 | | Parameter | Type | Description |
202 | | :-------- | :------- | :-------------------------------- |
203 | | `userid` | `string` | **Required**. Id of user |
204 |
205 | #### Create a photo
206 |
207 | ```http
208 | POST /api/users/${userid}/albums/${albumid}/photo
209 | ```
210 |
211 | | Parameter | Type | Description |
212 | | :-------- | :------- | :-------------------------------- |
213 | | `userid` | `string` | **Required**. Id of user |
214 | | `albumid` | `string` | **Required**. Id of album |
215 | | `file` | `binary` | **Required**. Name of photo |
216 |
217 | #### Get a photo
218 |
219 | ```http
220 | GET /api/photos/${photoid}
221 | ```
222 |
223 | | Parameter | Type | Description |
224 | | :-------- | :------- | :-------------------------------- |
225 | | `photoid` | `string` | **Required**. Id of photo |
226 |
227 | #### Update a photo
228 |
229 | ```http
230 | PUT /api/photos/${photoid}
231 | ```
232 |
233 | | Parameter | Type | Description |
234 | | :-------- | :------- | :-------------------------------- |
235 | | `photoid` | `string` | **Required**. Id of photo |
236 |
237 | #### Delete a photo
238 |
239 | ```http
240 | DELETE /api/photos/${photoid}
241 | ```
242 |
243 | | Parameter | Type | Description |
244 | | :-------- | :------- | :-------------------------------- |
245 | | `photoid` | `string` | **Required**. Id of photo |
246 |
247 | #### Get all photos
248 |
249 | ```http
250 | GET /api/users/${userid}/albums/${albumid}/photos
251 | ```
252 |
253 | | Parameter | Type | Description |
254 | | :-------- | :------- | :-------------------------------- |
255 | | `userid` | `string` | **Required**. Id of user |
256 | | `albumid` | `string` | **Required**. Id of album |
257 |
258 | ## Authors
259 |
260 | - [Siaw A. Nicholas](https://www.github.com/ayequill)
261 |
--------------------------------------------------------------------------------
/client/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | # Ignore artifacts:
3 | build/
4 | coverage/
5 |
6 | vite-env.d.ts
7 | vite.config.ts
8 | jest.config.js
9 | src/__tests__
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "plugin:react/recommended",
8 | "plugin:react-hooks/recommended",
9 | "airbnb",
10 | "plugin:@typescript-eslint/recommended",
11 | "prettier"
12 | ],
13 | "parser": "@typescript-eslint/parser",
14 | "parserOptions": {
15 | "ecmaFeatures": {
16 | "jsx": true
17 | },
18 | "ecmaVersion": 12,
19 | "sourceType": "module"
20 | },
21 | "plugins": [
22 | "react",
23 | "@typescript-eslint",
24 | "react-hooks",
25 | "prettier",
26 | "simple-import-sort"
27 | ],
28 | "settings": {
29 | "import/resolver": {
30 | "node": {
31 | "extensions": [".js", ".jsx", ".ts", ".tsx"]
32 | }
33 | }
34 | },
35 | "rules": {
36 | "no-use-before-define": "off",
37 | "react/react-in-jsx-scope": "off",
38 | "react/jsx-filename-extension": ["warn", { "extensions": [".tsx"] }],
39 | "react/jsx-props-no-spreading": "off",
40 | "linebreak-style": "off",
41 | "eol-last": "off",
42 | "max-len": ["warn", { "code": 80 }],
43 | "import/extensions": ["error", "never", { "svg": "always" }],
44 | "prettier/prettier": [
45 | "error",
46 | {
47 | "endOfLine": "auto"
48 | }
49 | ],
50 | "simple-import-sort/exports": "error",
51 | "simple-import-sort/imports": [
52 | "warn",
53 | {
54 | "groups": [
55 | // Node.js builtins. You could also generate this regex if you use a `.js` config.
56 | // For example: `^(${require("module").builtinModules.join("|")})(/|$)`
57 | [
58 | "^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)"
59 | ],
60 | // Packages
61 | ["^\\w"],
62 | // Internal packages.
63 | ["^(@|config/)(/*|$)"],
64 | // Side effect imports.
65 | ["^\\u0000"],
66 | // Parent imports. Put `..` last.
67 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"],
68 | // Other relative imports. Put same-folder imports and `.` last.
69 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
70 | // Style imports.
71 | ["^.+\\.s?css$"]
72 | ]
73 | }
74 | ],
75 | "import/no-anonymous-default-export": [
76 | "error",
77 | {
78 | "allowArrowFunction": true,
79 | "allowAnonymousFunction": true
80 | }
81 | ]
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | dist
4 | dist-ssr
5 | *.local
6 | coverage
7 | .env
8 | build
9 | yarn.lock
--------------------------------------------------------------------------------
/client/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | # Ignore artifacts:
3 | build/
4 | coverage/
5 |
6 | vite-env.d.ts
7 | vite.config.ts
8 | jest.config.js
--------------------------------------------------------------------------------
/client/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "tabWidth": 2,
4 | "singleQuote": true,
5 | "semi": true
6 | }
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
17 |
21 |
22 |
23 | ShutterSync - Photography Collaboration Platform
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/client/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['/src'],
3 | transform: {
4 | '^.+\\.tsx$': 'ts-jest',
5 | '^.+\\.ts$': 'ts-jest',
6 | },
7 | testRegex: '(/__tests__/.*.(test|spec)).(jsx?|tsx?)$',
8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
9 | collectCoverage: true,
10 | collectCoverageFrom: ['/src/**/*.{ts,tsx}'],
11 | coverageDirectory: '/coverage/',
12 | coveragePathIgnorePatterns: ['(tests/.*.mock).(jsx?|tsx?)$', '(.*).d.ts$'],
13 | moduleNameMapper: {
14 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2|svg)$':
15 | 'identity-obj-proxy',
16 | },
17 | verbose: true,
18 | testTimeout: 30000,
19 | };
20 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vibrant",
3 | "version": "0.0.0",
4 | "author": {
5 | "name": "David Sima",
6 | "email": "hello@david-sima.dev",
7 | "url": "https://github.com/The24thDS"
8 | },
9 | "private": true,
10 | "license": "MIT",
11 | "scripts": {
12 | "dev": "vite",
13 | "build": "tsc && vite build",
14 | "serve": "vite preview",
15 | "lint": "eslint src --ext .tsx --ext .ts",
16 | "lint:fix": "yarn lint --fix",
17 | "format": "prettier --config ./.prettierrc -w 'src/**/*.{tsx,ts}' && git update-index --again",
18 | "pretest": "yarn lint:fix",
19 | "test": "jest --colors --passWithNoTests",
20 | "test-ci": "CI=true jest",
21 | "posttest": "npx http-server coverage/lcov-report",
22 | "test:watch": "yarn test --collectCoverage=false --watch"
23 | },
24 | "dependencies": {
25 | "@chakra-ui/icons": "^2.0.17",
26 | "@chakra-ui/react": "^2.4.9",
27 | "@emotion/react": "^11.8.1",
28 | "@emotion/styled": "^11.8.1",
29 | "@fontsource-variable/nunito": "^5.0.16",
30 | "axios": "^1.6.1",
31 | "framer-motion": "^8.5.2",
32 | "react": "^18.2.0",
33 | "react-dom": "^18.2.0",
34 | "react-icons": "^4.11.0",
35 | "react-router-dom": "^6.18.0",
36 | "usehooks-ts": "^2.9.1"
37 | },
38 | "devDependencies": {
39 | "@babel/core": "^7.17.5",
40 | "@testing-library/jest-dom": "^5.16.2",
41 | "@testing-library/react": "^13.4.0",
42 | "@types/jest": "^29.2.6",
43 | "@types/node": "^22.2.0",
44 | "@types/react": "^18.0.27",
45 | "@types/react-dom": "^18.0.10",
46 | "@typescript-eslint/eslint-plugin": "^5.12.1",
47 | "@typescript-eslint/parser": "^5.12.1",
48 | "@vitejs/plugin-react": "^3.0.1",
49 | "eslint": "^8.53.0",
50 | "eslint-config-airbnb": "^19.0.4",
51 | "eslint-config-prettier": "^8.10.0",
52 | "eslint-plugin-import": "^2.25.4",
53 | "eslint-plugin-jsx-a11y": "^6.5.1",
54 | "eslint-plugin-prettier": "^5.0.0",
55 | "eslint-plugin-react": "^7.28.0",
56 | "eslint-plugin-react-hooks": "^4.3.0",
57 | "eslint-plugin-simple-import-sort": "^9.0.0",
58 | "identity-obj-proxy": "^3.0.0",
59 | "jest": "^29.4.0",
60 | "jest-environment-jsdom": "^29.5.0",
61 | "prettier": "^3.0.3",
62 | "rollup": "^3.10.1",
63 | "ts-jest": "^29.0.5",
64 | "typescript": "^4.5.5",
65 | "vite": "^4.0.4"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
2 |
3 | // eslint-disable-next-line import/no-extraneous-dependencies
4 | import '@fontsource-variable/nunito';
5 |
6 | import MainRouter from './MainRouter';
7 | import theme from './theme';
8 |
9 | const customStyles = `
10 | ::-webkit-scrollbar{
11 | width: .60rem;
12 | background-color: rgba(20, 20, 20, 0.301);
13 | border-radius: 5rem;
14 | }
15 | ::-webkit-scrollbar-thumb{
16 | background: rgba(122, 128, 138, 0.8);
17 | border-radius: .75rem;
18 | }
19 | `;
20 | function App(): JSX.Element {
21 | return (
22 |
23 | {/* eslint-disable-next-line max-len */}
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
31 | export default App;
32 |
--------------------------------------------------------------------------------
/client/src/MainRouter.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserRouter, Route, Routes } from 'react-router-dom';
2 |
3 | import Album from './components/albums/Album';
4 | import ClientView from './components/albums/ClientSideView';
5 | import Upload from './components/albums/Upload';
6 | import EmailConfirm from './components/auth/EmailConfirm';
7 | import Login from './components/auth/Login';
8 | import PrivateRoute from './components/auth/PrivateRoute';
9 | import ResetPassword from './components/auth/ResetPassword';
10 | import ResetPasswordRequest from './components/auth/ResetPasswordRequest';
11 | import SignUp from './components/auth/SignUp';
12 | import {
13 | AlbumProvider,
14 | AlbumsProvider,
15 | } from './components/contexts/albumContext';
16 | import { UserProvider } from './components/contexts/userContext';
17 | import ErrorPage from './components/core/ErrorPage';
18 | import Home from './components/core/Home';
19 | import MainLayout from './components/core/LayOut';
20 | import Dashboard from './components/user/Dashboard';
21 | import Profile from './components/user/Profile';
22 |
23 | function MainRouter() {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
34 |
35 |
36 | }
37 | />
38 |
42 |
43 |
44 | }
45 | />
46 |
50 |
51 |
52 | }
53 | />
54 |
58 |
59 |
60 | }
61 | >
62 | } />
63 | } />
64 | } />
65 | } />
66 |
67 |
71 |
72 |
73 | }
74 | />
75 |
79 |
80 |
81 | }
82 | />
83 |
87 |
88 |
89 | }
90 | />
91 | } />
92 |
96 |
97 |
98 | }
99 | />
100 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
108 | export default MainRouter;
109 |
--------------------------------------------------------------------------------
/client/src/__tests__/App.test.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import { render, screen } from '@testing-library/react';
6 |
7 | import '@testing-library/jest-dom';
8 |
9 | import App from '../App';
10 |
11 | describe('', () => {
12 | it('renders without errors', () => {
13 | render();
14 | });
15 | });
16 |
--------------------------------------------------------------------------------
/client/src/assets/bg.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/customer1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/customer1.webp
--------------------------------------------------------------------------------
/client/src/assets/flow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/flow.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/flow.webp
--------------------------------------------------------------------------------
/client/src/assets/gallery.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/client/src/assets/home.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/home.jpg
--------------------------------------------------------------------------------
/client/src/assets/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/home.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/home.webp
--------------------------------------------------------------------------------
/client/src/assets/lock.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/lock.webp
--------------------------------------------------------------------------------
/client/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/logo.png
--------------------------------------------------------------------------------
/client/src/assets/mobile-in-hand.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/mobile-in-hand.webp
--------------------------------------------------------------------------------
/client/src/assets/signup.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/client/src/assets/signup.webp
--------------------------------------------------------------------------------
/client/src/components/albums/AlbumDeleteModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import { Dispatch, SetStateAction, useCallback, useState } from 'react';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import {
6 | Button,
7 | Modal,
8 | ModalBody,
9 | ModalCloseButton,
10 | ModalContent,
11 | ModalFooter,
12 | ModalHeader,
13 | ModalOverlay,
14 | Spinner,
15 | Text,
16 | useToast,
17 | } from '@chakra-ui/react';
18 |
19 | import { useAlbums } from '../contexts/albumContext';
20 | import { useUser } from '../contexts/userContext';
21 |
22 | import { deleteAlbum, listAlbums } from './api-albums';
23 |
24 | interface DeleteModalProps {
25 | isOpen: boolean;
26 | onClose: Dispatch>;
27 | albumId: string | undefined;
28 | }
29 |
30 | function DeleteModal({ isOpen, onClose, albumId }: DeleteModalProps) {
31 | const [loader, setLoader] = useState(false);
32 | const { setAlbums } = useAlbums();
33 | const navigate = useNavigate();
34 | const toast = useToast();
35 |
36 | const { user } = useUser();
37 |
38 | const fetchAlbums = useCallback(
39 | async (id: string | undefined) => {
40 | await listAlbums(id).then((albums) => {
41 | if (albums.error) {
42 | // eslint-disable-next-line no-console
43 | console.log(albums.error);
44 | } else {
45 | setAlbums(albums);
46 | }
47 | });
48 | },
49 | [setAlbums]
50 | );
51 |
52 | const handleDeleteAlbum = useCallback(async () => {
53 | setLoader(true);
54 | try {
55 | if (user) {
56 | await deleteAlbum(albumId, user._id).then((data) => {
57 | if (data.message) {
58 | fetchAlbums(user._id);
59 | setLoader(false);
60 |
61 | onClose(false);
62 | toast({
63 | title: 'Album Deleted',
64 | status: 'success',
65 | duration: 1000,
66 | isClosable: true,
67 | onCloseComplete() {
68 | navigate('/dashboard');
69 | },
70 | });
71 | } else {
72 | setLoader(false);
73 | toast({
74 | title: 'Error Occurred',
75 | status: 'error',
76 | duration: 1000,
77 | isClosable: true,
78 | onCloseComplete() {
79 | onClose(false);
80 | window.location.reload();
81 | },
82 | });
83 | }
84 | });
85 | }
86 | } catch (e) {
87 | setLoader(false);
88 | }
89 | }, [albumId, fetchAlbums, navigate, onClose, toast, user]);
90 |
91 | return (
92 | onClose(false)}
96 | isCentered
97 | >
98 |
99 |
100 | Delete Album
101 |
102 |
103 |
104 | Are you sure you want to delete this album?
105 |
106 |
107 |
108 |
109 |
117 |
124 |
125 |
126 |
127 | );
128 | }
129 |
130 | export default DeleteModal;
131 |
--------------------------------------------------------------------------------
/client/src/components/albums/AlbumOptions.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { FaEllipsis } from 'react-icons/fa6';
3 |
4 | import { Button, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
5 |
6 | import { Album } from '../../utils/interfaces';
7 |
8 | import DeleteModal from './AlbumDeleteModal';
9 | import QuickEditModal from './AlbumQuickEditModal';
10 | import ShareModal from './AlbumShareModal';
11 |
12 | interface AlbumOptionsProps {
13 | handleClick: () => void;
14 | albumId: string | undefined;
15 | albumName: string;
16 | albumData: Album;
17 | }
18 |
19 | function AlbumOptions({
20 | handleClick,
21 | albumId,
22 | albumName,
23 | albumData,
24 | }: AlbumOptionsProps) {
25 | const [showDelete, setShowDelete] = useState(false);
26 | const [showQuickEdit, setShowQuickEdit] = useState(false);
27 | const [showShare, setShowShare] = useState(false);
28 |
29 | return (
30 | <>
31 |
42 |
47 |
53 |
60 | >
61 | );
62 | }
63 |
64 | export default AlbumOptions;
65 |
--------------------------------------------------------------------------------
/client/src/components/albums/AlbumQuickEditModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | /* eslint-disable no-underscore-dangle */
3 | import { Dispatch, SetStateAction, useCallback, useState } from 'react';
4 |
5 | import {
6 | Button,
7 | FormControl,
8 | FormLabel,
9 | Input,
10 | Modal,
11 | ModalBody,
12 | ModalCloseButton,
13 | ModalContent,
14 | ModalFooter,
15 | ModalHeader,
16 | ModalOverlay,
17 | Spinner,
18 | useToast,
19 | } from '@chakra-ui/react';
20 |
21 | import { useAlbums } from '../contexts/albumContext';
22 | import { useUser } from '../contexts/userContext';
23 |
24 | import { listAlbums, updateAlbumName } from './api-albums';
25 |
26 | interface QuickEditModalProps {
27 | isOpen: boolean;
28 | onClose: Dispatch>;
29 | albumID: string | undefined;
30 | albumName: string;
31 | }
32 | function QuickEditModal({
33 | isOpen,
34 | onClose,
35 | albumID,
36 | albumName,
37 | }: QuickEditModalProps) {
38 | const [loader, setLoader] = useState(false);
39 | const [newAlbumName, setNewAlbumName] = useState('');
40 | const toast = useToast();
41 | const { user } = useUser();
42 | const { setAlbums } = useAlbums();
43 |
44 | const fetchAlbums = useCallback(
45 | async (id: string | undefined) => {
46 | await listAlbums(id).then((albums) => {
47 | if (albums.error) {
48 | // eslint-disable-next-line no-console
49 | console.log(albums.error);
50 | } else {
51 | setAlbums(albums);
52 | }
53 | });
54 | },
55 | [setAlbums]
56 | );
57 |
58 | const handleAlbumNameChange = (e: any) => {
59 | setNewAlbumName(e.target.value);
60 | };
61 |
62 | const handleQuickEdit = useCallback(async () => {
63 | setLoader(true);
64 | try {
65 | if (user) {
66 | await updateAlbumName(albumID, user._id, { name: newAlbumName }).then(
67 | (data) => {
68 | if (data.message) {
69 | fetchAlbums(user._id);
70 | setLoader(false);
71 | toast({
72 | title: 'Album Updated',
73 | status: 'success',
74 | duration: 1000,
75 | isClosable: true,
76 | onCloseComplete() {
77 | onClose(false);
78 | },
79 | });
80 | } else {
81 | setLoader(false);
82 | toast({
83 | title: 'Error Occurred',
84 | status: 'error',
85 | duration: 1000,
86 | isClosable: true,
87 | onCloseComplete() {
88 | onClose(false);
89 | window.location.reload();
90 | },
91 | });
92 | }
93 | }
94 | );
95 | }
96 | } catch (e) {
97 | setLoader(false);
98 | }
99 | }, [albumID, fetchAlbums, newAlbumName, onClose, toast, user]);
100 |
101 | return (
102 | onClose(false)} isCentered>
103 |
104 |
105 | Quick Edit
106 |
107 |
108 |
109 | Album Name
110 |
115 |
116 |
117 |
118 |
126 |
133 |
134 |
135 |
136 | );
137 | }
138 |
139 | export default QuickEditModal;
140 |
--------------------------------------------------------------------------------
/client/src/components/albums/AlbumShareModal.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import {
3 | Dispatch,
4 | SetStateAction,
5 | useCallback,
6 | useEffect,
7 | useState,
8 | } from 'react';
9 | import { FaCopy } from 'react-icons/fa6';
10 | import { useNavigate } from 'react-router-dom';
11 |
12 | import {
13 | Button,
14 | Flex,
15 | FormControl,
16 | FormLabel,
17 | IconButton,
18 | Input,
19 | Modal,
20 | ModalBody,
21 | ModalCloseButton,
22 | ModalContent,
23 | ModalFooter,
24 | ModalHeader,
25 | ModalOverlay,
26 | Spinner,
27 | Switch,
28 | Text,
29 | useClipboard,
30 | useToast,
31 | } from '@chakra-ui/react';
32 |
33 | import { Album } from '../../utils/interfaces';
34 | import { useAlbums } from '../contexts/albumContext';
35 | import { useUser } from '../contexts/userContext';
36 |
37 | import { listAlbums, updateAlbum } from './api-albums';
38 |
39 | interface ShareModalProps {
40 | isOpen: boolean;
41 | onClose: Dispatch>;
42 | albumId: string | undefined;
43 | albumName: string | undefined;
44 | albumData: Album;
45 | }
46 |
47 | function ShareModal({
48 | isOpen,
49 | onClose,
50 | albumId,
51 | albumName,
52 | albumData,
53 | }: ShareModalProps) {
54 | const [loader, setLoader] = useState(false);
55 | const { setAlbums } = useAlbums();
56 | const navigate = useNavigate();
57 | const [values, setValues] = useState({
58 | locked: albumData?.locked,
59 | password: '',
60 | });
61 | const toast = useToast();
62 | const { onCopy, hasCopied } = useClipboard(
63 | `https://shuttersync.live/albums?id=${albumId}`
64 | );
65 |
66 | const { user } = useUser();
67 |
68 | const fetchAlbums = useCallback(
69 | async (id: string | undefined) => {
70 | await listAlbums(id).then((albums) => {
71 | if (albums.error) {
72 | // eslint-disable-next-line no-console
73 | console.log(albums.error);
74 | } else {
75 | setAlbums(albums);
76 | }
77 | });
78 | },
79 | [setAlbums]
80 | );
81 |
82 | const handleShareAlbum = useCallback(async () => {
83 | setLoader(true);
84 | try {
85 | if (values.locked && values.password === '') {
86 | toast({
87 | title: 'Password Required',
88 | description: 'Please enter a password to lock this album',
89 | status: 'error',
90 | position: 'top',
91 | duration: 1500,
92 | isClosable: true,
93 | });
94 | setLoader(false);
95 | return;
96 | }
97 | await updateAlbum(
98 | { password: values.password, locked: values.locked } as Album,
99 | albumId as string,
100 | user._id as string
101 | ).then((data) => {
102 | if (data.error) {
103 | // eslint-disable-next-line no-console
104 | console.log(data.error);
105 | setLoader(false);
106 | } else {
107 | toast({
108 | title: data?.locked ? 'Album Locked' : 'Album Unlocked',
109 | status: 'success',
110 | position: 'top',
111 | duration: 1500,
112 | isClosable: true,
113 | onCloseComplete() {
114 | fetchAlbums(user?._id);
115 | setLoader(false);
116 | onClose(false);
117 | navigate(`/dashboard`);
118 | },
119 | });
120 | }
121 | });
122 | } catch (e) {
123 | setLoader(false);
124 | }
125 | }, [
126 | albumId,
127 | fetchAlbums,
128 | navigate,
129 | onClose,
130 | toast,
131 | user._id,
132 | values.locked,
133 | values.password,
134 | ]);
135 |
136 | const handleShareValues = (e: React.ChangeEvent) => {
137 | setValues({
138 | ...values,
139 | [e.target.name]:
140 | e.target.name === 'locked' ? e.target.checked : e.target.value,
141 | });
142 | };
143 |
144 | useEffect(() => {
145 | if (hasCopied) {
146 | toast({
147 | title: 'Copied',
148 | status: 'success',
149 | position: 'top',
150 | duration: 1500,
151 | isClosable: true,
152 | });
153 | }
154 | }, [hasCopied, toast]);
155 |
156 | return (
157 | onClose(false)}
161 | isCentered
162 | >
163 |
164 |
165 | Share {albumName}
166 |
167 |
168 |
169 |
170 | Lock Album
171 | handleShareValues(e)}
174 | size="lg"
175 | name="locked"
176 | isDisabled={loader}
177 | />
178 |
179 | {values.locked && (
180 | handleShareValues(e)}
186 | isDisabled={loader}
187 | />
188 | )}
189 |
197 |
198 | {`https://shuttersync.live/albums?id=${albumId}`}
199 |
200 | }
204 | onClick={onCopy}
205 | />
206 |
207 |
208 |
209 |
210 |
211 |
219 |
226 |
227 |
228 |
229 | );
230 | }
231 |
232 | export default ShareModal;
233 |
--------------------------------------------------------------------------------
/client/src/components/albums/AlbumThumbnails.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import { motion } from 'framer-motion';
3 | import { lazy, memo, Suspense, useEffect, useState } from 'react';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { Divider, Flex, Image, Spinner, Text, VStack } from '@chakra-ui/react';
7 |
8 | import { Album, Photo } from '../../utils/interfaces';
9 | import { isAuthenticated } from '../auth/auth-helper';
10 | import { useAlbum } from '../contexts/albumContext';
11 | import { useUser } from '../contexts/userContext';
12 |
13 | const AlbumOptions = lazy(() => import('./AlbumOptions'));
14 |
15 | interface AlbumProps {
16 | album: Album;
17 | }
18 |
19 | const AlbumThumbnails = memo(({ album }: AlbumProps) => {
20 | const [cover, setCover] = useState();
21 | const { setAlbum } = useAlbum();
22 | const { setUser } = useUser();
23 | const navigate = useNavigate();
24 |
25 | /* Method to view an album */
26 | const handleAlbumClick = () => {
27 | setAlbum(album);
28 | navigate(`/dashboard/album/${album?._id}`);
29 | };
30 |
31 | /* Setting album cover by using the first pic. Will update later */
32 | useEffect(() => {
33 | if (album && album.photos && album?.photos?.length > 0) {
34 | setCover(album?.photos[0]);
35 | }
36 | if (isAuthenticated()) setUser(isAuthenticated()?.user);
37 | }, [album, album.photos, setUser]);
38 |
39 | const datePublished = album?.createdAt
40 | ? new Date(album.createdAt).toLocaleDateString()
41 | : '';
42 | const photos = album?.photos || [];
43 |
44 | return (
45 |
53 |
60 |
68 |
78 |
94 | 0 ? 'pointer' : 'default'}
112 | transition="transform 0.3s ease-in-out"
113 | fallbackSrc="https://placehold.co/600x400?text=No+Image"
114 | />
115 |
116 |
123 |
131 | {album.name}
132 |
133 | }>
134 |
140 |
141 |
142 |
150 | {datePublished}
151 | {album.locked ? 'Published' : 'Not Shared'}
152 |
153 |
154 | {album.photos?.length} photos
155 |
156 |
157 |
158 |
159 |
160 |
161 | );
162 | });
163 |
164 | export default AlbumThumbnails;
165 |
--------------------------------------------------------------------------------
/client/src/components/albums/Albums.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import { lazy, Suspense } from 'react';
3 |
4 | import { Flex, Spinner } from '@chakra-ui/react';
5 |
6 | import { Album } from '../../utils/interfaces';
7 | import { useAlbums } from '../contexts/albumContext';
8 |
9 | const AlbumThumbnails = lazy(() => import('./AlbumThumbnails'));
10 |
11 | export default function Collections() {
12 | const { albums } = useAlbums();
13 | const albumComponents = albums.map((album: Album) => (
14 | } key={album._id}>
15 |
16 |
17 | ));
18 | return (
19 |
30 | {albumComponents}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/client/src/components/albums/ClientSideView.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable no-underscore-dangle */
3 | import { useEffect, useState } from 'react';
4 | import { useNavigate, useSearchParams } from 'react-router-dom';
5 | import { useTimeout } from 'usehooks-ts';
6 |
7 | import {
8 | Box,
9 | Button,
10 | Flex,
11 | FormControl,
12 | FormErrorMessage,
13 | FormHelperText,
14 | HStack,
15 | Image,
16 | Input,
17 | Modal,
18 | ModalBody,
19 | ModalCloseButton,
20 | ModalContent,
21 | ModalFooter,
22 | ModalHeader,
23 | ModalOverlay,
24 | Spinner,
25 | } from '@chakra-ui/react';
26 |
27 | import { Album } from '../../utils/interfaces';
28 | import { useAlbum } from '../contexts/albumContext';
29 | import LoaderComponent from '../core/Loader';
30 |
31 | import { checkAlbumPassword, getSingleAlbum } from './api-albums';
32 |
33 | function ClientView() {
34 | const [unlocked, setUnlocked] = useState(false);
35 | const [loader, setLoader] = useState(false);
36 | const [cover, setCover] = useState('');
37 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
38 | const [searchParams, setSearchParams] = useSearchParams();
39 | const { album, setAlbum } = useAlbum();
40 |
41 | const albumId = searchParams.get('id') as string;
42 |
43 | const hide = () => setLoader(false);
44 |
45 | useTimeout(hide, 500);
46 |
47 | const photos = album?.photos?.map((photo) => (
48 |
55 | setCover(photo.imageUrl)}
57 | src={photo.imageUrl}
58 | alt={photo.name}
59 | loading="eager"
60 | w="100%"
61 | h="100%"
62 | />
63 |
64 | ));
65 |
66 | useEffect(() => {
67 | setLoader(true);
68 | const fetchAlbum = async () => {
69 | try {
70 | await getSingleAlbum(albumId).then((data: Album) => {
71 | setAlbum(data);
72 | if (data.photos) setCover(data.photos[0].imageUrl);
73 | });
74 | } catch (e) {
75 | console.log(e);
76 | }
77 | };
78 |
79 | if (albumId) {
80 | fetchAlbum();
81 | }
82 | }, [albumId, setAlbum]);
83 |
84 | if (loader) {
85 | return ;
86 | }
87 |
88 | return (
89 |
90 | {album.photos && (
91 |
92 |
101 |
102 | )}
103 |
104 | {photos}
105 |
106 | {!unlocked && album?.locked && (
107 |
108 | )}
109 |
110 | );
111 | }
112 |
113 | interface PasswordModalProps {
114 | albumId: string;
115 | setUnlocked: React.Dispatch>;
116 | }
117 |
118 | function PasswordModal({ albumId, setUnlocked }: PasswordModalProps) {
119 | const { album } = useAlbum();
120 | const navigate = useNavigate();
121 | const [loader, setLoader] = useState(false);
122 | const [password, setPassword] = useState('');
123 | const [error, setError] = useState(false);
124 |
125 | const handleModalClose = async () => {
126 | setLoader(true);
127 | try {
128 | if (album.locked) {
129 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
130 | await checkAlbumPassword(albumId, password).then((data: any) => {
131 | console.log(data);
132 | if (data.success) {
133 | setUnlocked(true);
134 | setLoader(false);
135 | return data;
136 | }
137 | setUnlocked(false);
138 | setError(true);
139 | setLoader(false);
140 | return data;
141 | });
142 | }
143 | } catch (e) {
144 | console.log(e);
145 | }
146 | };
147 |
148 | const handleKeyDown = (e: React.KeyboardEvent) => {
149 | if (e.key === 'Enter') {
150 | handleModalClose();
151 | }
152 | };
153 |
154 | const handleChange = (e: React.ChangeEvent) => {
155 | setPassword(e.target.value);
156 | };
157 |
158 | console.log(error);
159 |
160 | return (
161 |
167 |
168 |
169 | Share
170 |
171 |
172 |
173 |
174 | {album?.locked && (
175 | handleChange(e)}
181 | isDisabled={loader}
182 | onKeyDown={(e) => handleKeyDown(e)}
183 | />
184 | )}
185 | {!error ? (
186 | Enter Album Password
187 | ) : (
188 | Incorrect Password
189 | )}
190 |
191 |
192 |
193 |
194 |
195 |
203 |
210 |
211 |
212 |
213 | );
214 | }
215 |
216 | export default ClientView;
217 |
--------------------------------------------------------------------------------
/client/src/components/albums/Upload.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | /* eslint-disable @typescript-eslint/no-explicit-any */
3 | /* eslint-disable no-console */
4 | import React, { useState } from 'react';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | import {
8 | Box,
9 | Button,
10 | Flex,
11 | FormControl,
12 | FormLabel,
13 | Input,
14 | Spinner,
15 | Text,
16 | VStack,
17 | } from '@chakra-ui/react';
18 |
19 | import { isAuthenticated } from '../auth/auth-helper';
20 | import { useUser } from '../contexts/userContext';
21 | import LoaderComponent from '../core/Loader';
22 | import useTimeout from '../hooks/useTimeOut';
23 | import { addPhotos } from '../user/api-photos';
24 |
25 | import { createAlbum } from './api-albums';
26 |
27 | function Upload() {
28 | const [albumName, setAlbumName] = useState('');
29 | const [selectedPhotos, setSelectedPhotos] = useState([]);
30 | const [error, setError] = useState('');
31 | const [loader, setLoader] = useState(true);
32 | const [isLoading, setIsLoading] = useState(false);
33 | const navigate = useNavigate();
34 | const { user } = useUser();
35 |
36 | const userID = isAuthenticated()?.user?._id || user._id;
37 |
38 | const hide = () => setLoader(false);
39 | useTimeout(hide, 1000);
40 |
41 | const handleAlbumNameChange = (e: any) => {
42 | setAlbumName(e.target.value);
43 | };
44 |
45 | const handlePhotoChange = (e: any) => {
46 | const files = Array.from(e.target.files || []) as File[];
47 | setSelectedPhotos(files);
48 | };
49 |
50 | const handleSubmit = () => {
51 | if (albumName === '' || albumName === undefined) {
52 | setError('Please enter a valid album name');
53 | return;
54 | }
55 | setAlbumName(albumName.trim());
56 |
57 | if (selectedPhotos.length === 0) {
58 | setError('Please select at least one photo');
59 | return;
60 | }
61 | setIsLoading(true);
62 | // eslint-disable-next-line no-underscore-dangle
63 | createAlbum({ name: albumName }, userID).then((data) => {
64 | if (data.error) {
65 | console.log(data.error);
66 | } else {
67 | // eslint-disable-next-line no-underscore-dangle
68 | addPhotos(data._id, userID, selectedPhotos).then(() => {
69 | setIsLoading(false);
70 | navigate(`/dashboard/album/${data._id}`);
71 | });
72 | }
73 | });
74 | };
75 | if (loader) {
76 | return ;
77 | }
78 |
79 | return (
80 |
81 |
82 | Add new album
83 |
84 |
85 |
86 | Album Name
87 |
88 |
96 |
97 |
98 |
99 | Add new photos
100 |
101 |
102 |
103 |
111 |
112 | Select Photos
113 |
120 |
121 | Select one or more photos (JPEG, JPG, PNG)
122 |
123 | {error && {error}}
124 |
125 |
126 |
127 |
128 |
136 |
144 |
145 | );
146 | }
147 |
148 | export default Upload;
149 |
--------------------------------------------------------------------------------
/client/src/components/albums/api-albums.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import axios, { AxiosHeaders } from 'axios';
3 |
4 | import { Album } from '../../utils/interfaces';
5 |
6 | const KEY = import.meta.env.VITE_KEY as string;
7 |
8 | const axiosInstance = axios.create({
9 | // eslint-disable-next-line no-underscore-dangle
10 | baseURL: `${import.meta.env.VITE_API_URL}/api/users`,
11 | // timeout: 30000,
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | Accept: 'application/json',
15 | 'x-api-key': KEY,
16 | },
17 | });
18 |
19 | const createAlbum = async (album: Album, userId: string) => {
20 | try {
21 | const response = await axiosInstance.post(`${userId}/albums`, album);
22 | return response.data;
23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
24 | } catch (e: any) {
25 | // eslint-disable-next-line no-console
26 | if (e.response) {
27 | return { error: e.response.data.error } as { error: string };
28 | }
29 | return { error: 'Please try again later' } as { error: string };
30 | }
31 | };
32 |
33 | const listAlbums = async (userId: string | undefined) => {
34 | try {
35 | const response = await axiosInstance.get(`${userId}/albums`);
36 | return response.data;
37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
38 | } catch (e: any) {
39 | if (e.response) {
40 | return { error: e.response.data.error } as { error: string };
41 | }
42 | return { error: 'Please try again later' } as { error: string };
43 | }
44 | };
45 |
46 | const getAlbum = async (albumId: string, userId: string) => {
47 | try {
48 | const response = await axiosInstance.get(`${userId}/albums/${albumId}`);
49 | return response.data;
50 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
51 | } catch (e: any) {
52 | if (e.response) {
53 | return { error: e.response.data.error } as { error: string };
54 | }
55 | return { error: 'Please try again later' } as { error: string };
56 | }
57 | };
58 |
59 | const updateAlbumName = async (
60 | albumId: string | undefined,
61 | userId: string | undefined,
62 | album: Album
63 | ) => {
64 | try {
65 | const response = await axiosInstance.put(
66 | `${userId}/albums/${albumId}`,
67 | album
68 | );
69 | return response.data;
70 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
71 | } catch (e: any) {
72 | if (e.response) {
73 | return { error: e.response.data.error } as { error: string };
74 | }
75 | return { error: 'Please try again later' } as { error: string };
76 | }
77 | };
78 |
79 | const deleteAlbum = async (
80 | albumId: string | undefined,
81 | userId: string | undefined
82 | ) => {
83 | try {
84 | const response = await axiosInstance.delete(`${userId}/albums/${albumId}`);
85 | return response.data;
86 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
87 | } catch (e: any) {
88 | if (e.response) {
89 | return { error: e.response.data.error } as { error: string };
90 | }
91 | return { error: 'Please try again later' } as { error: string };
92 | }
93 | };
94 |
95 | const updateAlbum = async (album: Album, albumId: string, userId: string) => {
96 | try {
97 | const response = await axiosInstance.put(
98 | `${userId}/albums/${albumId}`,
99 | album
100 | );
101 | return response.data;
102 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
103 | } catch (e: any) {
104 | if (e.response) {
105 | return { error: e.response.data.error } as { error: string };
106 | }
107 | return { error: 'Please try again later' } as { error: string };
108 | }
109 | };
110 |
111 | const clientInstance = axios.create({
112 | // eslint-disable-next-line no-underscore-dangle
113 | baseURL: `${import.meta.env.VITE_API_URL}/api/albums`,
114 | // timeout: 30000,
115 | headers: {
116 | 'Content-Type': 'application/json',
117 | Accept: 'application/json',
118 | 'x-api-key': KEY,
119 | },
120 | });
121 | const getSingleAlbum = async (albumId: string) => {
122 | try {
123 | const response = await clientInstance.get(`?id=${albumId}`);
124 | return response.data;
125 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
126 | } catch (e: any) {
127 | if (e.response) {
128 | return { error: e.response.data.message } as { error: string };
129 | }
130 | return { error: 'Please try again later' } as { error: string };
131 | }
132 | };
133 |
134 | const checkAlbumPassword = async (albumId: string, password: string) => {
135 | try {
136 | const response = await clientInstance.post(`?id=${albumId}`, { password });
137 | return response.data;
138 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
139 | } catch (e: any) {
140 | if (e.response) {
141 | return { error: e.response.data } as { error: string };
142 | }
143 | return { error: 'Please try again later' } as { error: string };
144 | }
145 | };
146 |
147 | export {
148 | checkAlbumPassword,
149 | createAlbum,
150 | deleteAlbum,
151 | getAlbum,
152 | getSingleAlbum,
153 | listAlbums,
154 | updateAlbum,
155 | updateAlbumName,
156 | };
157 |
--------------------------------------------------------------------------------
/client/src/components/auth/EmailConfirm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 |
3 | import { Box, Flex, Text } from '@chakra-ui/react';
4 |
5 | import LoaderComponent from '../core/Loader';
6 | import useTimeout from '../hooks/useTimeOut';
7 |
8 | import { checkEmail } from './api-auth';
9 |
10 | export default function EmailConfirm() {
11 | const [loader, setLoader] = React.useState(true);
12 | const [values, setValues] = React.useState({
13 | confirmed: '',
14 | error: '',
15 | });
16 | const token = window.location.pathname.split('/')[2];
17 | const hide = () => setLoader(false);
18 | useTimeout(hide, 3000);
19 |
20 | useEffect(() => {
21 | checkEmail(token).then((data) => {
22 | if (data.error) {
23 | setValues({ ...values, error: data.error });
24 | } else {
25 | setValues({ ...values, confirmed: data.message });
26 | }
27 | });
28 | // eslint-disable-next-line react-hooks/exhaustive-deps
29 | }, []);
30 |
31 | if (loader) {
32 | return ;
33 | }
34 | return (
35 |
44 |
45 | {values.confirmed ? (
46 |
47 | You've successfully confirmed your email.
48 |
You can login in now.
49 |
50 | ) : (
51 | {values.error}
52 | )}
53 |
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/components/auth/Login.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import React, { useEffect } from 'react';
3 | import { FaEye, FaEyeSlash } from 'react-icons/fa6';
4 | import { Link as ReactRouterLink, useNavigate } from 'react-router-dom';
5 |
6 | import {
7 | Box,
8 | Button,
9 | Center,
10 | Flex,
11 | FormControl,
12 | FormHelperText,
13 | Grid,
14 | Heading,
15 | Image,
16 | Input,
17 | InputGroup,
18 | InputRightElement,
19 | Link,
20 | Spinner,
21 | Text,
22 | VStack,
23 | } from '@chakra-ui/react';
24 |
25 | import Lock from '../../assets/lock.webp';
26 | import { useUser } from '../contexts/userContext';
27 | import LoaderComponent from '../core/Loader';
28 | import useTimeout from '../hooks/useTimeOut';
29 |
30 | import { signin } from './api-auth';
31 | import { authenticate } from './auth-helper';
32 |
33 | function Login(): JSX.Element {
34 | const [values, setValues] = React.useState({
35 | email: '',
36 | password: '',
37 | error: '',
38 | redirect: false,
39 | });
40 | const [isError, setIsError] = React.useState(false);
41 | const [isLoading, setIsLoading] = React.useState(false);
42 | const [loader, setLoader] = React.useState(true);
43 | const [showPassword, setShowPassword] = React.useState(false);
44 | const { setUser } = useUser();
45 | const navigate = useNavigate();
46 |
47 | useEffect(() => {
48 | if (values.redirect) {
49 | navigate('/dashboard');
50 | }
51 | }, [values.redirect, navigate]);
52 |
53 | useEffect(() => {
54 | document.title = 'Sign In | ShutterSync';
55 | }, []);
56 |
57 | const hide = () => setLoader(false);
58 | useTimeout(hide, 2000);
59 |
60 | // if (loader) {
61 | // return ;
62 | // }
63 | const handleInputChange =
64 | (name: string) => (event: React.ChangeEvent) => {
65 | setValues({
66 | ...values,
67 | [name]: event.target.value,
68 | });
69 | };
70 | const handleClickShowPassword = () => setShowPassword(!showPassword);
71 |
72 | const handleSubmit = () => {
73 | const { email, password } = values;
74 | const doThrowError = (email && password) === '';
75 | if (doThrowError) {
76 | setIsError(true);
77 | } else {
78 | setIsError(false);
79 | }
80 | if (!doThrowError) {
81 | setIsLoading(true);
82 | signin({
83 | email,
84 | password,
85 | }).then((data) => {
86 | setIsLoading(false);
87 | if (data.error) {
88 | setValues({
89 | ...values,
90 | error: data.error,
91 | });
92 | } else {
93 | authenticate(data, () => {
94 | setValues({
95 | ...values,
96 | error: '',
97 | redirect: true,
98 | });
99 | setUser(data.user);
100 | });
101 | }
102 | });
103 | }
104 | };
105 |
106 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
107 | // @ts-ignore
108 | return (
109 |
110 |
122 |
134 |
142 |
143 |
144 | {/*
145 | ShutterSync
146 |
147 | Your digital photos companion */}
148 |
149 |
155 |
156 |
157 | Hello Again!
158 |
159 |
160 | Welcome Back to ShutterSync.
161 |
Please login to your account.
162 |
163 |
164 |
165 |
166 |
167 | {/* Email */}
168 |
176 | {/* Password */}
177 |
178 |
185 |
186 |
193 |
194 |
195 | {!values.error ? (
196 |
197 | We'll never share your email.
198 |
199 | ) : (
200 |
201 | {values.error}
202 |
203 | )}
204 |
205 |
212 |
213 | Don't have an account?{' '}
214 |
220 | Register
221 |
222 |
223 |
224 | Forgot password?{' '}
225 |
231 | Reset
232 |
233 |
234 |
235 |
236 |
237 |
238 |
239 | );
240 | }
241 |
242 | export default Login;
243 |
--------------------------------------------------------------------------------
/client/src/components/auth/PrivateRoute.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate, Outlet } from 'react-router-dom';
2 |
3 | import { isAuthenticated } from './auth-helper';
4 |
5 | // eslint-disable-next-line react/prop-types,@typescript-eslint/ban-ts-comment
6 | // @ts-ignore
7 | // eslint-disable-next-line react/prop-types
8 | function PrivateRoute() {
9 | if (!isAuthenticated())
10 | return (
11 |
16 | );
17 | return ;
18 | }
19 |
20 | export default PrivateRoute;
21 |
--------------------------------------------------------------------------------
/client/src/components/auth/ResetPassword.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import React, { useEffect } from 'react';
3 | import { FaEye, FaEyeSlash } from 'react-icons/fa6';
4 | import { useNavigate, useParams } from 'react-router-dom';
5 |
6 | import {
7 | Box,
8 | Button,
9 | Flex,
10 | FormControl,
11 | FormHelperText,
12 | Grid,
13 | Heading,
14 | Input,
15 | InputGroup,
16 | InputRightElement,
17 | Spinner,
18 | Text,
19 | useToast,
20 | VStack,
21 | } from '@chakra-ui/react';
22 |
23 | import LoaderComponent from '../core/Loader';
24 | import useTimeout from '../hooks/useTimeOut';
25 |
26 | import { checkEmail, resetPassword } from './api-auth';
27 |
28 | function ResetPassword(): JSX.Element {
29 | const [values, setValues] = React.useState({
30 | email: '',
31 | password: '',
32 | newPassword: '',
33 | error: '',
34 | redirect: false,
35 | });
36 | const [isError, setIsError] = React.useState(false);
37 | const [isLoading, setIsLoading] = React.useState(false);
38 | const [loader, setLoader] = React.useState(true);
39 | const [showPassword, setShowPassword] = React.useState(false);
40 | const toast = useToast();
41 | const navigate = useNavigate();
42 | const { token } = useParams();
43 |
44 | useEffect(() => {
45 | document.title = 'Reset Password | ShutterSync';
46 | }, []);
47 |
48 | const handleInputChange =
49 | (name: string) => (event: React.ChangeEvent) => {
50 | setValues({
51 | ...values,
52 | [name]: event.target.value,
53 | });
54 | };
55 |
56 | const handleClickShowPassword = () => setShowPassword(!showPassword);
57 |
58 | const hide = () => setLoader(false);
59 | useTimeout(hide, 1500);
60 |
61 | const handleSubmit = () => {
62 | const { password, newPassword } = values;
63 | const doThrowError = (password && newPassword) === '';
64 | if (doThrowError) {
65 | setIsError(true);
66 | } else {
67 | setIsError(false);
68 | }
69 | if (password !== newPassword) {
70 | setIsError(true);
71 | setValues({
72 | ...values,
73 | error: 'Passwords do not match',
74 | });
75 | return;
76 | }
77 | if (!doThrowError) {
78 | setIsLoading(true);
79 | resetPassword({
80 | email: values.email,
81 | password: values.password,
82 | newPassword: values.newPassword,
83 | }).then((data) => {
84 | if (data.error) {
85 | setIsLoading(false);
86 | setValues({
87 | ...values,
88 | error: data.error,
89 | });
90 | } else {
91 | toast({
92 | title: 'Password Updated',
93 | description: 'Please login with new password. Redirecting...',
94 | status: 'success',
95 | duration: 2000,
96 | isClosable: true,
97 | onCloseComplete() {
98 | setIsLoading(false);
99 | navigate('/signin');
100 | },
101 | });
102 | }
103 | });
104 | }
105 | };
106 |
107 | const emailChecker = (t: string | undefined) => {
108 | checkEmail(t).then((data) => {
109 | if (data.error) {
110 | console.log(data);
111 | }
112 | if (data.user) {
113 | setValues({
114 | ...values,
115 | email: data.user.email,
116 | });
117 | }
118 | });
119 | };
120 |
121 | React.useEffect(() => {
122 | emailChecker(token);
123 | // eslint-disable-next-line react-hooks/exhaustive-deps
124 | }, []);
125 |
126 | if (loader) {
127 | return ;
128 | }
129 |
130 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
131 | // @ts-ignore
132 | return (
133 |
134 |
146 |
156 |
157 | ShutterSync
158 |
159 | Your digital photos companion
160 |
161 |
162 |
163 |
164 | Please fill the form to reset password
165 |
166 |
167 |
168 |
169 |
170 |
171 |
178 |
179 |
186 |
187 |
188 |
189 |
196 |
197 |
204 |
205 |
206 | {!values.error ? (
207 |
208 | {/* We'll never share your email. */}
209 |
210 | ) : (
211 |
212 | {values.error}
213 |
214 | )}
215 |
216 |
219 |
220 |
221 |
222 |
223 |
224 | );
225 | }
226 |
227 | export default ResetPassword;
228 |
--------------------------------------------------------------------------------
/client/src/components/auth/ResetPasswordRequest.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import {
5 | Box,
6 | Button,
7 | Flex,
8 | FormControl,
9 | FormHelperText,
10 | Grid,
11 | Heading,
12 | Input,
13 | InputGroup,
14 | Spinner,
15 | Text,
16 | useToast,
17 | VStack,
18 | } from '@chakra-ui/react';
19 |
20 | import LoaderComponent from '../core/Loader';
21 | import useTimeout from '../hooks/useTimeOut';
22 |
23 | import { resetPasswordRequest } from './api-auth';
24 |
25 | function ResetPasswordRequest(): JSX.Element {
26 | const [values, setValues] = React.useState({
27 | email: '',
28 | error: '',
29 | redirect: false,
30 | });
31 | const [isError, setIsError] = React.useState(false);
32 | const [isLoading, setIsLoading] = React.useState(false);
33 | const [loader, setLoader] = React.useState(true);
34 | const toast = useToast();
35 | const navigate = useNavigate();
36 |
37 | useEffect(() => {
38 | document.title = 'Reset Password | ShutterSync';
39 | }, []);
40 |
41 | const handleInputChange =
42 | (name: string) => (event: React.ChangeEvent) => {
43 | setValues({
44 | ...values,
45 | [name]: event.target.value,
46 | });
47 | };
48 | const hide = () => setLoader(false);
49 | useTimeout(hide, 2000);
50 |
51 | const handleSubmit = () => {
52 | const { email } = values;
53 | const doThrowError = email === '';
54 | if (doThrowError) {
55 | setIsError(true);
56 | } else {
57 | setIsError(false);
58 | }
59 | if (!doThrowError) {
60 | setIsLoading(true);
61 | resetPasswordRequest(email).then((data) => {
62 | if (data.error) {
63 | setIsLoading(false);
64 | setValues({
65 | ...values,
66 | error: data.error,
67 | });
68 | } else {
69 | toast({
70 | title: 'Password reset request sent',
71 | description: 'Please check your email for further details',
72 | status: 'success',
73 | duration: 2000,
74 | isClosable: true,
75 | onCloseComplete() {
76 | setIsLoading(false);
77 | navigate('/signin');
78 | },
79 | });
80 | }
81 | });
82 | }
83 | };
84 |
85 | if (loader) {
86 | return ;
87 | }
88 |
89 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
90 | // @ts-ignore
91 | return (
92 |
93 |
105 |
115 |
116 | ShutterSync
117 |
118 | Your digital photos companion
119 |
120 |
121 |
122 |
123 | Please enter your email address to reset password
124 |
125 |
126 |
127 |
128 |
129 | {/* Email */}
130 |
138 | {!values.error ? (
139 |
140 | {/* We'll never share your email. */}
141 |
142 | ) : (
143 |
144 | {values.error}
145 |
146 | )}
147 |
148 |
151 |
152 |
153 |
154 |
155 |
156 | );
157 | }
158 |
159 | export default ResetPasswordRequest;
160 |
--------------------------------------------------------------------------------
/client/src/components/auth/SignUp.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import React, { useEffect } from 'react';
3 | import { FaEye, FaEyeSlash } from 'react-icons/fa6';
4 | import { Link as ReactRouterLink, Navigate } from 'react-router-dom';
5 |
6 | import {
7 | Alert,
8 | AlertDescription,
9 | AlertIcon,
10 | AlertTitle,
11 | Box,
12 | Button,
13 | Center,
14 | Flex,
15 | FormControl,
16 | FormHelperText,
17 | FormLabel,
18 | Grid,
19 | Heading,
20 | Image,
21 | Input,
22 | InputGroup,
23 | InputRightElement,
24 | Link,
25 | Spinner,
26 | Text,
27 | VStack,
28 | } from '@chakra-ui/react';
29 |
30 | import SignUpImage from '../../assets/signup.webp';
31 | import isStrongPassword from '../../utils/misc';
32 | import LoaderComponent from '../core/Loader';
33 | import useTimeout from '../hooks/useTimeOut';
34 | import { create } from '../user/api-user';
35 |
36 | import { isAuthenticated } from './auth-helper';
37 |
38 | function SignUp(): JSX.Element {
39 | const [values, setValues] = React.useState({
40 | name: '',
41 | email: '',
42 | password: '',
43 | error: '',
44 | open: false,
45 | });
46 | const [isError, setIsError] = React.useState(false);
47 | const [loader, setLoader] = React.useState(true);
48 | const [showPassword, setShowPassword] = React.useState(false);
49 | const [isLoading, setIsLoading] = React.useState(false);
50 |
51 | useEffect(() => {
52 | document.title = 'Sign Up | ShutterSync';
53 | }, []);
54 |
55 | useEffect(() => {
56 | if (values.password) {
57 | if (!isStrongPassword(values.password)) {
58 | setValues({
59 | ...values,
60 | error: `Password is not strong`,
61 | });
62 | } else {
63 | setValues({
64 | ...values,
65 | error: '',
66 | });
67 | }
68 | }
69 | // eslint-disable-next-line react-hooks/exhaustive-deps
70 | }, [values.password]);
71 |
72 | const hide = () => setLoader(false);
73 | useTimeout(hide, 2000);
74 |
75 | // if (loader) {
76 | // return ;
77 | // }
78 |
79 | const handleClickShowPassword = () => setShowPassword(!showPassword);
80 |
81 | const handleInputChange =
82 | (name: string) => (event: React.ChangeEvent) => {
83 | setValues({
84 | ...values,
85 | [name]: event.target.value,
86 | });
87 | };
88 |
89 | const handleSubmit = () => {
90 | const { name, email, password } = values;
91 | const doThrowError = (email && password && name) === '';
92 | if (doThrowError) {
93 | setIsError(true);
94 | } else {
95 | setIsError(false);
96 | }
97 |
98 | if (!doThrowError) {
99 | setIsLoading(true);
100 | create({
101 | name,
102 | email,
103 | password,
104 | }).then((data) => {
105 | if (data.error) {
106 | setIsLoading(false);
107 | setValues({
108 | ...values,
109 | error: data.error,
110 | });
111 | }
112 | if (!data.error) {
113 | setValues({
114 | ...values,
115 | error: '',
116 | open: true,
117 | });
118 | }
119 | });
120 | }
121 | };
122 | if (values.open) {
123 | // return ;
124 | return ;
125 | }
126 | if (isAuthenticated()) {
127 | return ;
128 | }
129 |
130 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
131 | // @ts-ignore
132 | return (
133 |
134 |
146 |
157 |
165 |
166 |
167 |
168 |
174 |
175 |
176 | Welcome!
177 |
178 | Join ShutterSync.
179 |
180 |
181 |
182 |
183 | Name
184 |
191 | Email address
192 |
199 |
200 | We'll never share your email.
201 |
202 | Password
203 |
204 |
211 |
212 |
219 |
220 |
221 | {!values.error ? (
222 |
223 | We'll never share your email.
224 |
225 | ) : (
226 |
232 | {values.error}
233 |
234 | )}
235 |
236 |
243 |
244 | Already have an account?{' '}
245 |
251 | Log in
252 |
253 |
254 |
255 |
256 |
257 |
258 |
259 | );
260 | }
261 |
262 | function AccountCreated() {
263 | return (
264 |
265 |
275 |
276 |
277 | Account created!
278 |
279 |
280 | Thanks for signing up. Please check your email to confirm your account
281 | or{' '}
282 |
290 | log in
291 | {' '}
292 | to your account.
293 |
294 |
295 |
296 | );
297 | }
298 | export default SignUp;
299 |
--------------------------------------------------------------------------------
/client/src/components/auth/api-auth.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import axios from 'axios';
3 |
4 | const KEY = import.meta.env.VITE_KEY as string;
5 | const axiosInstance = axios.create({
6 | baseURL: `${import.meta.env.VITE_API_URL}/auth`,
7 | headers: {
8 | 'Content-Type': 'application/json',
9 | Accept: 'application/json',
10 | 'x-api-key': KEY,
11 | },
12 | });
13 |
14 | const signin = async (user: { email: string; password: string }) => {
15 | try {
16 | const response = await axiosInstance.post('/signin', user, {
17 | withCredentials: true,
18 | });
19 | return response.data;
20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
21 | } catch (e: any) {
22 | if (e.response) {
23 | return { error: e.response.data.error } as { error: string };
24 | }
25 | return { error: 'Please try again later' } as { error: string };
26 | }
27 | };
28 |
29 | const signout = async () => {
30 | try {
31 | const response = await axiosInstance.get('/signout');
32 | return response.data;
33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
34 | } catch (e: any) {
35 | if (e.response) {
36 | return { error: e.response.data.error } as { error: string };
37 | }
38 | return { error: 'Please try again later' } as { error: string };
39 | }
40 | };
41 |
42 | const checkEmail = async (token: string | undefined) => {
43 | try {
44 | const res = await axiosInstance.get('/verify', { params: { token } });
45 | return res.data;
46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
47 | } catch (e: any) {
48 | if (e.response) {
49 | return { error: e.response.data.error } as { error: string };
50 | }
51 | return { error: 'Please try again later' } as { error: string };
52 | }
53 | };
54 |
55 | const resetPassword = async (user: {
56 | email: string;
57 | password: string;
58 | newPassword: string;
59 | }) => {
60 | try {
61 | const response = await axiosInstance.put('/reset', user);
62 | return response.data;
63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
64 | } catch (e: any) {
65 | if (e.response) {
66 | return { error: e.response.data.error } as { error: string };
67 | }
68 | return { error: 'Please try again later' } as { error: string };
69 | }
70 | };
71 |
72 | const resetPasswordRequest = async (email: string) => {
73 | try {
74 | const response = await axiosInstance.post('/reset', { email });
75 | return response.data;
76 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
77 | } catch (e: any) {
78 | if (e.response) {
79 | return { error: e.response.data.error } as { error: string };
80 | }
81 | return { error: 'Please try again later' } as { error: string };
82 | }
83 | };
84 |
85 | export { checkEmail, resetPassword, resetPasswordRequest, signin, signout };
86 |
--------------------------------------------------------------------------------
/client/src/components/auth/auth-helper.ts:
--------------------------------------------------------------------------------
1 | import { signout } from './api-auth';
2 |
3 | export function authenticate(jwt: string, cb: () => void) {
4 | if (typeof window !== 'undefined')
5 | localStorage.setItem('jwt', JSON.stringify(jwt));
6 | cb();
7 | }
8 |
9 | export function isAuthenticated() {
10 | if (typeof window === 'undefined') return false;
11 | const storedToken = localStorage.getItem('jwt');
12 | return storedToken ? JSON.parse(storedToken) : false;
13 | }
14 |
15 | export async function clearJWT(cb: () => void) {
16 | if (typeof window !== 'undefined') localStorage.removeItem('jwt');
17 | cb();
18 | await signout().then((data) => {
19 | document.cookie = 't=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
20 | return data;
21 | });
22 | }
23 |
--------------------------------------------------------------------------------
/client/src/components/contexts/albumContext.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | Dispatch,
4 | ReactNode,
5 | SetStateAction,
6 | useContext,
7 | useMemo,
8 | useState,
9 | } from 'react';
10 |
11 | import { Album } from '../../utils/interfaces';
12 |
13 | interface AlbumsContextProps {
14 | albums: Album[];
15 | setAlbums: Dispatch>;
16 | }
17 |
18 | const AlbumsContext = createContext(undefined);
19 |
20 | interface AlbumProviderProps {
21 | children: ReactNode;
22 | }
23 |
24 | export function AlbumsProvider({ children }: AlbumProviderProps) {
25 | const [albums, setAlbums] = useState([]);
26 |
27 | const contextValue = useMemo(
28 | () => ({ albums, setAlbums }),
29 | [albums, setAlbums]
30 | );
31 |
32 | return (
33 |
34 | {children}
35 |
36 | );
37 | }
38 |
39 | export const useAlbums = () => {
40 | const context = useContext(AlbumsContext);
41 | if (context === undefined) {
42 | throw new Error('useAlbums must be used within a AlbumProvider');
43 | }
44 | return context;
45 | };
46 |
47 | interface AlbumContextProps {
48 | album: Album;
49 | setAlbum: Dispatch>;
50 | }
51 |
52 | const AlbumContext = createContext(undefined);
53 |
54 | export function AlbumProvider({ children }: AlbumProviderProps) {
55 | const [album, setAlbum] = useState({} as Album);
56 |
57 | const contextValue = useMemo(() => ({ album, setAlbum }), [album, setAlbum]);
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useAlbum = () => {
67 | const context = useContext(AlbumContext);
68 | if (context === undefined) {
69 | throw new Error('useAlbum must be used within a AlbumProvider');
70 | }
71 | return context;
72 | };
73 |
--------------------------------------------------------------------------------
/client/src/components/contexts/userContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | Dispatch,
4 | ReactNode,
5 | SetStateAction,
6 | useContext,
7 | useMemo,
8 | useState,
9 | } from 'react';
10 |
11 | import { User } from '../../utils/interfaces';
12 |
13 | interface UserProps {
14 | user: User;
15 | setUser: Dispatch>;
16 | }
17 |
18 | interface UserProviderProps {
19 | children: ReactNode;
20 | }
21 |
22 | const UserContext = createContext(undefined);
23 |
24 | export function UserProvider({ children }: UserProviderProps) {
25 | const [user, setUser] = useState({} as User);
26 |
27 | const contextValue = useMemo(() => ({ user, setUser }), [user, setUser]);
28 |
29 | return (
30 | {children}
31 | );
32 | }
33 |
34 | export const useUser = () => {
35 | const context = useContext(UserContext);
36 | if (context === undefined) {
37 | throw new Error('useUser must be within a valid provider');
38 | }
39 | return context;
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/components/core/ErrorPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useNavigate } from 'react-router-dom';
3 |
4 | import { Box, Button, Center, Image } from '@chakra-ui/react';
5 |
6 | import ErrorImage from '../../assets/404.svg';
7 |
8 | function ErrorPage() {
9 | const navigate = useNavigate();
10 |
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
24 |
25 |
26 | );
27 | }
28 |
29 | export default ErrorPage;
30 |
--------------------------------------------------------------------------------
/client/src/components/core/Features.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 |
3 | import { Flex, Image, SimpleGrid, Text } from '@chakra-ui/react';
4 |
5 | import Customer from '../../assets/customer1.webp';
6 | import Flow from '../../assets/flow.webp';
7 | import MobileInHand from '../../assets/mobile-in-hand.webp';
8 |
9 | function Features() {
10 | return (
11 |
17 |
36 |
41 |
47 |
48 |
59 |
65 | Effortlessly organize and showcase your stunning portfolio with
66 | ShutterSync intuitive gallery management. Seamlessly upload,
67 | arrange, and update your work to create a visual narrative that
68 | captivates clients and enhances your professional image.
69 |
70 |
71 |
72 |
73 |
92 |
108 |
115 |
116 |
132 |
138 | Foster meaningful interactions with clients through
139 | ShutterSync's collaborative features. Invite feedback, share
140 | drafts, and streamline communication to ensure that every project
141 | unfolds with precision.
142 |
143 |
144 |
145 |
146 |
165 |
175 |
182 |
183 |
184 |
200 |
206 | From initial concept to final delivery, ShutterSync optimizes your
207 | project workflow. Enjoy a centralized hub for all project assets,
208 | streamline file sharing, and track project progress effortlessly.
209 | Enhance your project management capabilities, allowing you to focus
210 | more on what you love – capturing extraordinary moments.
211 |
212 |
213 |
214 |
215 | );
216 | }
217 |
218 | export default Features;
219 |
--------------------------------------------------------------------------------
/client/src/components/core/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { FaGithub, FaLinkedin, FaXTwitter } from 'react-icons/fa6';
2 |
3 | import { Flex, Icon, Link, Text, Tooltip } from '@chakra-ui/react';
4 |
5 | export default function Footer() {
6 | return (
7 |
17 |
18 |
23 | Shutter
24 |
25 | Sync
26 | © 2023
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/client/src/components/core/Home.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import React, { lazy, Suspense } from 'react';
3 | import { FaAnglesRight } from 'react-icons/fa6';
4 | import { useNavigate } from 'react-router-dom';
5 |
6 | import { Box, Button, Flex, Image, Text, VStack } from '@chakra-ui/react';
7 |
8 | import HomeBG from '../../assets/home.webp';
9 | import { isAuthenticated } from '../auth/auth-helper';
10 | import useTimeout from '../hooks/useTimeOut';
11 |
12 | import LoaderComponent from './Loader';
13 |
14 | const Features = lazy(() => import('./Features'));
15 |
16 | function Home(): JSX.Element {
17 | const [isLoading, setIsLoading] = React.useState(true);
18 | const navigate = useNavigate();
19 |
20 | const hide = () => setIsLoading(false);
21 | useTimeout(hide, 1500);
22 |
23 | React.useEffect(() => {
24 | if (isAuthenticated()) {
25 | navigate('/dashboard', { replace: true });
26 | }
27 | // eslint-disable-next-line react-hooks/exhaustive-deps
28 | }, []);
29 |
30 | if (isLoading) {
31 | return ;
32 | }
33 |
34 | return (
35 |
42 |
56 |
57 |
58 |
65 | Join Shutter
66 | {' '}
67 | Sync
68 |
69 |
75 | ShutterSync aims to address the need for professional photographers
76 | to efficiently share and collaborate on their work with clients,
77 | enhancing client engagement and project management.
78 |
79 |
80 | }
82 | _hover={{ boxShadow: 'xl', transform: 'scale(1.03)' }}
83 | _dark={{ bg: 'blue.500' }}
84 | color="white"
85 | bg="blackAlpha.900"
86 | onClick={() => navigate('/signin')}
87 | >
88 | Get started for free
89 |
90 |
91 |
92 |
101 |
102 |
103 |
104 |
105 | }>
106 |
107 |
108 |
109 | );
110 | }
111 |
112 | export default Home;
113 |
--------------------------------------------------------------------------------
/client/src/components/core/LayOut.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode, useEffect } from 'react';
2 |
3 | import { Box } from '@chakra-ui/react';
4 |
5 | import { isAuthenticated } from '../auth/auth-helper';
6 | import { useUser } from '../contexts/userContext';
7 |
8 | import Footer from './Footer';
9 | import Navbar from './NavBar';
10 |
11 | interface MainLayoutProps {
12 | children: ReactNode;
13 | }
14 |
15 | function MainLayout({ children }: MainLayoutProps) {
16 | const { setUser } = useUser();
17 |
18 | useEffect(() => {
19 | if (isAuthenticated()) setUser(isAuthenticated().user);
20 | }, [setUser]);
21 | return (
22 |
23 |
24 |
25 | {children}
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | export default MainLayout;
33 |
--------------------------------------------------------------------------------
/client/src/components/core/Loader.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { Box, Spinner } from '@chakra-ui/react';
4 |
5 | function LoaderComponent() {
6 | return (
7 |
20 |
26 |
27 | );
28 | }
29 |
30 | export default LoaderComponent;
31 |
--------------------------------------------------------------------------------
/client/src/components/core/NavBar.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from 'framer-motion';
2 | import React from 'react';
3 | import { FaTimes } from 'react-icons/fa';
4 | import { Link as ReactRouterLink, useNavigate } from 'react-router-dom';
5 |
6 | import { HamburgerIcon } from '@chakra-ui/icons';
7 | import {
8 | Box,
9 | Button,
10 | Flex,
11 | HStack,
12 | Link,
13 | Text,
14 | useDisclosure,
15 | } from '@chakra-ui/react';
16 |
17 | import { clearJWT, isAuthenticated } from '../auth/auth-helper';
18 |
19 | import ThemeToggleButton from './ThemeToggleButton';
20 |
21 | function Navbar() {
22 | const navigate = useNavigate();
23 |
24 | const handleLogout = async () => {
25 | clearJWT(() => navigate('/', { replace: true }));
26 | };
27 |
28 | return (
29 |
38 |
46 |
47 | ShutterSync
48 |
49 |
50 |
57 | {!isAuthenticated() ? (
58 |
59 |
69 | SignIn
70 |
71 |
82 |
88 | Get Started
89 |
90 |
91 |
92 |
93 | ) : (
94 |
95 |
106 |
117 |
127 |
128 |
129 | )}
130 |
131 | {/* */}
132 |
133 |
134 |
135 |
136 | );
137 | }
138 |
139 | interface ProfileMenuProps {
140 | logout: () => void;
141 | }
142 |
143 | function MobileDrawerL({ logout }: ProfileMenuProps) {
144 | const { isOpen, onOpen, onClose } = useDisclosure();
145 | const variants = {
146 | open: { opacity: 1, y: '-10px', type: 'spring' },
147 | closed: { opacity: 0, y: '10px' },
148 | };
149 |
150 | const handleClick = (cb: () => void) => {
151 | cb();
152 | onClose();
153 | };
154 | return (
155 |
162 |
174 |
181 |
198 |
199 |
216 | {isAuthenticated() && (
217 |
227 | )}
228 |
229 |
230 | {isOpen ? (
231 |
232 | ) : (
233 |
234 | )}
235 |
236 |
237 | );
238 | }
239 |
240 | export default Navbar;
241 |
--------------------------------------------------------------------------------
/client/src/components/core/ThemeToggleButton.tsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon, SunIcon } from '@chakra-ui/icons';
2 | import { IconButton, IconButtonProps, useColorMode } from '@chakra-ui/react';
3 | import styled from '@emotion/styled';
4 |
5 | import transientOptions from '../../utils/general';
6 |
7 | // PROP TYPES
8 | type ThemeToggleButtonProps = Omit;
9 |
10 | // CONSTS and LETS
11 | const iconSize = 20;
12 | interface RoundButtonProps {
13 | $colorMode: 'light' | 'dark';
14 | }
15 |
16 | const RoundButton = styled(IconButton, transientOptions)`
17 | ${({ $colorMode }) => ($colorMode === 'light' ? 'black' : 'white')};
18 | & svg {
19 | width: ${iconSize}px;
20 | height: ${iconSize}px;
21 | }
22 | `;
23 |
24 | function ThemeToggleButton(props: ThemeToggleButtonProps): JSX.Element {
25 | const { colorMode, toggleColorMode } = useColorMode();
26 |
27 | return (
28 | : }
32 | aria-label={`Activate ${colorMode === 'light' ? 'dark' : 'light'} mode`}
33 | isRound
34 | position="fixed"
35 | bottom="30px"
36 | right="25px"
37 | size="lg"
38 | _hover={{
39 | transition: 'transform 0.3s ease-in-out',
40 | transform: 'scale(1.3)',
41 | }}
42 | {...props}
43 | />
44 | );
45 | }
46 |
47 | export default ThemeToggleButton;
48 |
--------------------------------------------------------------------------------
/client/src/components/hooks/useTimeOut.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | // eslint-disable-next-line import/no-extraneous-dependencies
3 | import { useIsomorphicLayoutEffect } from 'usehooks-ts';
4 |
5 | export default function useTimeout(callback: () => void, delay: number | null) {
6 | const savedCallback = useRef(callback);
7 |
8 | // Remember the latest callback if it changes.
9 | useIsomorphicLayoutEffect(() => {
10 | savedCallback.current = callback;
11 | }, [callback]);
12 |
13 | // Set up the timeout.
14 | useEffect(() => {
15 | // Don't schedule if no delay is specified.
16 | // Note: 0 is a valid value for delay.
17 | if (!delay && delay !== 0) {
18 | return;
19 | }
20 |
21 | const id = setTimeout(() => savedCallback.current(), delay);
22 |
23 | // eslint-disable-next-line consistent-return
24 | return () => clearTimeout(id);
25 | }, [delay]);
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/components/user/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-hooks/exhaustive-deps */
2 | /* eslint-disable no-underscore-dangle */
3 | /* eslint-disable @typescript-eslint/no-unused-vars */
4 | import { lazy, Suspense, useCallback, useEffect, useState } from 'react';
5 | import { FaPlus } from 'react-icons/fa6';
6 | import { useNavigate } from 'react-router-dom';
7 |
8 | import { Box, Button, Container, Flex, Text } from '@chakra-ui/react';
9 |
10 | import { listAlbums } from '../albums/api-albums';
11 | import { isAuthenticated } from '../auth/auth-helper';
12 | import { useAlbums } from '../contexts/albumContext';
13 | import { useUser } from '../contexts/userContext';
14 | import LoaderComponent from '../core/Loader';
15 |
16 | const Collections = lazy(() => import('../albums/Albums'));
17 | function Dashboard() {
18 | const { albums, setAlbums } = useAlbums();
19 | const { user, setUser } = useUser();
20 | const [loader, setLoader] = useState(true);
21 | const navigate = useNavigate();
22 |
23 | useEffect(() => {
24 | document.title = 'Dashboard | ShutterSync';
25 | }, []);
26 |
27 | const userID = isAuthenticated()?.user._id;
28 |
29 | const fetchAlbums = useCallback(
30 | async (id: string | undefined) => {
31 | setLoader(true);
32 | try {
33 | if (user) {
34 | const userId = userID || user._id;
35 | await listAlbums(userId).then((data) => {
36 | if (data.error) {
37 | // eslint-disable-next-line no-console
38 | console.log(data.error);
39 | setLoader(false);
40 | } else {
41 | setAlbums(data);
42 | }
43 | });
44 | }
45 | } finally {
46 | setLoader(false);
47 | }
48 | },
49 | [user._id]
50 | );
51 |
52 | useEffect(() => {
53 | fetchAlbums(user._id);
54 | }, [fetchAlbums, user._id]);
55 |
56 | if (loader) return ;
57 |
58 | return (
59 |
60 |
69 | {/* Collections */}
70 |
71 | }
74 | rounded={30}
75 | fontSize="sm"
76 | fontWeight="bold"
77 | onClick={() => navigate('upload')}
78 | variant="outline"
79 | >
80 | Create New Collection
81 |
82 |
83 |
84 | {albums?.length === 0 ? (
85 |
86 | ) : (
87 | }>
88 |
89 |
90 | )}
91 |
92 | );
93 | }
94 |
95 | function EmptyCollections() {
96 | return (
97 |
98 | Nothing here. Please add a new collection
99 |
100 | );
101 | }
102 |
103 | export default Dashboard;
104 |
--------------------------------------------------------------------------------
/client/src/components/user/Profile.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable no-underscore-dangle */
3 | import { useCallback, useEffect, useState } from 'react';
4 |
5 | import {
6 | Box,
7 | Button,
8 | Flex,
9 | FormControl,
10 | Input,
11 | Spinner,
12 | Text,
13 | useToast,
14 | VStack,
15 | } from '@chakra-ui/react';
16 |
17 | import { User } from '../../utils/interfaces';
18 | import { resetPassword } from '../auth/api-auth';
19 | import { isAuthenticated } from '../auth/auth-helper';
20 | import { useUser } from '../contexts/userContext';
21 | import LoaderComponent from '../core/Loader';
22 |
23 | import { read, update } from './api-user';
24 |
25 | type UserNewWithNewPassword = User & { newPassword: string };
26 |
27 | function Profile() {
28 | const { user, setUser } = useUser();
29 | const [values, setValues] = useState({
30 | name: '',
31 | email: '',
32 | password: '',
33 | newPassword: '',
34 | });
35 | const [component, setComponent] = useState('name');
36 | const [isLoading, setIsLoading] = useState(false);
37 | const [loarder, setLoader] = useState(false);
38 |
39 | const userId = user?._id || isAuthenticated().user._id;
40 | const { token } = isAuthenticated();
41 | const toast = useToast();
42 |
43 | const fetchUser = useCallback(async () => {
44 | const abortController = new AbortController();
45 | const { signal } = abortController;
46 | setIsLoading(true);
47 | await read(userId, { t: token }, signal).then((data) => {
48 | setUser(data);
49 | setIsLoading(false);
50 | });
51 | // eslint-disable-next-line react-hooks/exhaustive-deps
52 | }, []);
53 |
54 | const handleUpdateUser = async () => {
55 | if (user && component !== 'password') {
56 | setLoader(true);
57 | await update(user._id, token, {
58 | ...values,
59 | email: user.email,
60 | name: values.name ? values.name : user.name,
61 | }).then((data) => {
62 | if (data.error) {
63 | // eslint-disable-next-line no-console
64 | console.log(data.error);
65 | } else {
66 | console.log(data);
67 | toast({
68 | title: 'Password Updated',
69 | // description: 'Please login with new password. Redirecting...',
70 | status: 'success',
71 | duration: 2000,
72 | isClosable: true,
73 | onCloseComplete() {
74 | setUser(data);
75 | setLoader(false);
76 | window.location.reload();
77 | },
78 | });
79 | }
80 | });
81 | } else if (user && component === 'password') {
82 | setLoader(true);
83 | await resetPassword({
84 | email: user.email,
85 | newPassword: values.newPassword,
86 | password: values.password,
87 | }).then((data) => {
88 | if (data.error) {
89 | // eslint-disable-next-line no-console
90 | console.log(data.error);
91 | } else {
92 | toast({
93 | title: data.message,
94 | // description: 'Please login with new password. Redirecting...',
95 | status: 'success',
96 | duration: 2000,
97 | isClosable: true,
98 | onCloseComplete() {
99 | setUser(data);
100 | setLoader(false);
101 | window.location.reload();
102 | },
103 | });
104 | // navigate('/albums');
105 | }
106 | });
107 | }
108 | };
109 |
110 | useEffect(() => {
111 | fetchUser();
112 | // eslint-disable-next-line react-hooks/exhaustive-deps
113 | }, []);
114 |
115 | if (isLoading) {
116 | return ;
117 | }
118 |
119 | const returnComponentTitle = () => {
120 | switch (component) {
121 | case 'name':
122 | return 'Change name';
123 | case 'email':
124 | return 'Change email';
125 | case 'password':
126 | return 'Change password';
127 | default:
128 | return 'Change name';
129 | }
130 | };
131 |
132 | return (
133 |
139 |
140 |
141 |
142 | {returnComponentTitle()}
143 |
144 |
145 | {component === 'name' && (
146 | setValues({ ...values, name: e.target.value })}
151 | />
152 | )}
153 | {component === 'email' && (
154 | setValues({ ...values, email: e.target.value })}
159 | />
160 | )}
161 | {component === 'password' && (
162 |
163 |
170 | setValues({ ...values, password: e.target.value })
171 | }
172 | />
173 |
180 | setValues({ ...values, newPassword: e.target.value })
181 | }
182 | />
183 |
184 | )}
185 |
193 |
194 |
195 |
196 | );
197 | }
198 |
199 | interface QuickSettingsProps {
200 | componentSetter: React.Dispatch>;
201 | setValues: React.Dispatch>;
202 | }
203 |
204 | function QuickSettings({ componentSetter, setValues }: QuickSettingsProps) {
205 | const handleComponentSetter = (component: string) => {
206 | componentSetter(component);
207 | setValues((prev) => {
208 | const newValues: UserNewWithNewPassword = { ...prev };
209 | Object.keys(prev).forEach((key) => {
210 | if (key !== component) {
211 | newValues[key as keyof UserNewWithNewPassword] = '';
212 | }
213 | });
214 | return newValues;
215 | });
216 | };
217 |
218 | return (
219 |
226 | handleComponentSetter('name')}
230 | >
231 |
232 | Change name
233 |
234 |
235 | handleComponentSetter('email')}
239 | >
240 |
241 | Update email
242 |
243 |
244 | handleComponentSetter('password')}
248 | >
249 |
250 | Change password
251 |
252 |
253 |
254 | );
255 | }
256 |
257 | export default Profile;
258 |
--------------------------------------------------------------------------------
/client/src/components/user/api-photos.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unused-vars */
2 | import axios from 'axios';
3 |
4 | const KEY = import.meta.env.VITE_KEY as string;
5 |
6 | const axiosInstance = axios.create({
7 | // eslint-disable-next-line no-underscore-dangle
8 | baseURL: `${import.meta.env.VITE_API_URL}/api/users`,
9 | // timeout: 30000,
10 | headers: {
11 | 'Content-Type': 'application/json',
12 | Accept: 'application/json',
13 | 'x-api-key': KEY,
14 | },
15 | });
16 |
17 | const addPhotos = async (albumId: string, userId: string, photos: File[]) => {
18 | try {
19 | const formData = new FormData();
20 | // formData.append('name', albumId.name);
21 | photos.forEach((photo) => {
22 | formData.append('photos', photo);
23 | });
24 | const response = await axiosInstance.post(
25 | `/${userId}/albums/${albumId}/photo`,
26 | formData,
27 | {
28 | headers: {
29 | 'Content-Type': 'multipart/form-data',
30 | },
31 | }
32 | );
33 | return response.data;
34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
35 | } catch (e: any) {
36 | // eslint-disable-next-line no-console
37 | if (e.response) {
38 | return { error: e.response.data.error } as { error: string };
39 | }
40 | return { error: 'Please try again later' } as { error: string };
41 | }
42 | };
43 |
44 | const photoInstance = axios.create({
45 | baseURL: `${import.meta.env.VITE_API_URL}/api/photos`,
46 | headers: {
47 | 'Content-Type': 'application/json',
48 | Accept: 'application/json',
49 | 'x-api-key': KEY,
50 | },
51 | });
52 |
53 | const deletePhoto = async (photoId: string | undefined) => {
54 | try {
55 | const response = await photoInstance.delete(`${photoId}`);
56 | return response.data;
57 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
58 | } catch (e: any) {
59 | if (e.response) {
60 | return { error: e.response.data.error } as { error: string };
61 | }
62 | return { error: 'Please try again later' } as { error: string };
63 | }
64 | };
65 |
66 | // eslint-disable-next-line import/prefer-default-export
67 | export { addPhotos, deletePhoto };
68 |
--------------------------------------------------------------------------------
/client/src/components/user/api-user.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line import/no-extraneous-dependencies
2 | import axios, { AxiosHeaders } from 'axios';
3 |
4 | import { User } from '../../utils/interfaces';
5 |
6 | const KEY = import.meta.env.VITE_KEY as string;
7 | const axiosInstance = axios.create({
8 | baseURL: `${import.meta.env.VITE_API_URL}/api`,
9 |
10 | timeout: 10000,
11 | headers: {
12 | 'Content-Type': 'application/json',
13 | Accept: 'application/json',
14 | 'x-api-key': KEY,
15 | },
16 | });
17 |
18 | const create = async (user: User) => {
19 | try {
20 | const response = await axiosInstance.post('/users', user);
21 | return response.data;
22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
23 | } catch (e: any) {
24 | if (e.response) {
25 | return { error: e.response.data.error } as { error: string };
26 | }
27 | return { error: 'Please try again later' } as { error: string };
28 | }
29 | };
30 |
31 | const list = async (signal: AbortSignal) => {
32 | try {
33 | const response = await axiosInstance.get('/users', {
34 | signal,
35 | });
36 | return response.data;
37 | } catch (e) {
38 | // eslint-disable-next-line no-console
39 | console.log(e);
40 | return e;
41 | }
42 | };
43 |
44 | const read = async (
45 | userId: string,
46 | credentials: {
47 | t: AxiosHeaders | string;
48 | },
49 | signal: AbortSignal
50 | ) => {
51 | try {
52 | const response = await axiosInstance.get(`/users/${userId}`, {
53 | headers: {
54 | Authorization: `Bearer ${credentials.t}`,
55 | },
56 | signal,
57 | });
58 | return response.data;
59 | } catch (e) {
60 | // eslint-disable-next-line no-console
61 | console.log(e);
62 | return e;
63 | }
64 | };
65 |
66 | const update = async (
67 | userId: string | undefined,
68 | token: string,
69 | user: unknown
70 | ) => {
71 | try {
72 | const response = await axiosInstance.put(`/users/${userId}`, user, {
73 | headers: {
74 | Authorization: `Bearer ${token}`,
75 | 'Content-Type': 'application/json',
76 | Accept: 'application/json',
77 | },
78 | });
79 | return response.data;
80 | } catch (e) {
81 | // eslint-disable-next-line no-console
82 | console.log(e);
83 | return e;
84 | }
85 | };
86 |
87 | const deleteUser = async (
88 | params: { userId: string },
89 | credentials: { t: string }
90 | ) => {
91 | try {
92 | const response = await axiosInstance.delete(`/users/${params.userId}`, {
93 | headers: {
94 | Authorization: `Bearer ${credentials.t}`,
95 | 'Content-Type': 'application/json',
96 | Accept: 'application/json',
97 | },
98 | });
99 | return response.data;
100 | } catch (e) {
101 | // eslint-disable-next-line no-console
102 | console.log(e);
103 | return e;
104 | }
105 | };
106 |
107 | export { create, deleteUser, list, read, update };
108 |
--------------------------------------------------------------------------------
/client/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { createRoot } from 'react-dom/client';
3 |
4 | import App from './App';
5 |
6 | const container: HTMLElement | null = document.getElementById('root');
7 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
8 | const root = createRoot(container!);
9 | root.render(
10 | //
11 |
12 | //
13 | );
14 |
--------------------------------------------------------------------------------
/client/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme, ThemeConfig } from '@chakra-ui/react';
2 |
3 | const config: ThemeConfig = {
4 | initialColorMode: 'light',
5 | useSystemColorMode: false,
6 | };
7 |
8 | const fonts = {
9 | body: 'Nunito Variable, sans-serif',
10 | };
11 |
12 | const styles = {
13 | global: {
14 | 'html, body': {
15 | _dark: { bg: 'gray.900' },
16 | scrollBehavior: 'smooth',
17 | position: 'relative',
18 | },
19 | },
20 | };
21 |
22 | const theme = extendTheme({ config, fonts, styles });
23 |
24 | export default theme;
25 |
--------------------------------------------------------------------------------
/client/src/utils/general.ts:
--------------------------------------------------------------------------------
1 | import { CreateStyled } from '@emotion/styled';
2 |
3 | export const transientOptions: Parameters[1] = {
4 | shouldForwardProp: (propName: string) => !propName.startsWith('$'),
5 | };
6 |
7 | export default transientOptions;
8 |
--------------------------------------------------------------------------------
/client/src/utils/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface User {
2 | _id?: string;
3 | name: string;
4 | email: string;
5 | password: string;
6 | }
7 |
8 | export interface Photo {
9 | _id?: string;
10 | album: string;
11 | imageUrl: string;
12 | name: string;
13 | created_at: string;
14 | storageUrl: string;
15 | }
16 |
17 | export interface Album {
18 | _id?: string;
19 | name: string;
20 | photos?: Photo[];
21 | createdAt?: Date;
22 | locked?: boolean;
23 | password?: string;
24 | }
25 |
--------------------------------------------------------------------------------
/client/src/utils/misc.ts:
--------------------------------------------------------------------------------
1 | export default function isStrongPassword(password: string): boolean {
2 | // Define the regular expressions for the criteria
3 | const minLengthRegex = /.{8,}/; // Minimum 8 characters
4 | const uppercaseRegex = /[A-Z]/; // At least one uppercase letter
5 | const lowercaseRegex = /[a-z]/; // At least one lowercase letter
6 | const digitRegex = /\d/; // At least one digit
7 | const specialCharRegex = /[!@#$%^&*(),.?":{}|<>]/;
8 | // Check if the password meets all criteria
9 | const meetsMinLength = minLengthRegex.test(password);
10 | const hasUppercase = uppercaseRegex.test(password);
11 | const hasLowercase = lowercaseRegex.test(password);
12 | const hasDigit = digitRegex.test(password);
13 | const hasSpecialChar = specialCharRegex.test(password);
14 |
15 | return (
16 | meetsMinLength && hasUppercase && hasLowercase && hasDigit && hasSpecialChar
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "allowJs": false,
6 | "skipLibCheck": true,
7 | "esModuleInterop": false,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "ESNext",
12 | "moduleResolution": "Node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 | "allowUmdGlobalAccess": true
18 | },
19 | "include": ["./src"]
20 | }
21 |
--------------------------------------------------------------------------------
/client/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {"source": "/(.*)", "destination": "/"}
4 | ]
5 | }
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | host: true
9 | },
10 | build: {
11 | rollupOptions: {
12 | output: {
13 | manualChunks: {
14 | react: ['react', 'react-dom', 'react-router-dom'],
15 | chakra: ['@chakra-ui/react', '@chakra-ui/icons'],
16 | reactIcons: ['react-icons'],
17 | emotion: ['@emotion/react', '@emotion/styled'],
18 | framer: ['framer-motion'],
19 | axios: ['axios'],
20 |
21 | }
22 | }
23 | }
24 | }
25 | });
26 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | extends: ['@commitlint/config-conventional'],
3 | };
4 |
5 |
6 |
--------------------------------------------------------------------------------
/jest.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "verbose": true,
3 | "collectCoverage": true,
4 | "collectCoverageFrom": [
5 | "server/**/*.js"
6 | ],
7 | "coverageReporters": ["lcov", "text", "html"],
8 | "testEnvironment": "jest-environment-node",
9 | "testPathIgnorePatterns": [
10 | "/node_modules/",
11 | "/client/"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["./server"],
3 | "ext": "js,json",
4 | "ignore": ["node_modules"],
5 | "execMap": {
6 | "js": "node --inspect"
7 | },
8 | "delay": "1000",
9 | "env": {
10 | "NODE_ENV": "development"
11 | },
12 | "events": {
13 | "restart": "osascript -e 'display notification \"App restarted due to changes\" with title \"nodemon\"'"
14 | },
15 | "legacyWatch": true
16 | }
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ShutterSync",
3 | "version": "1.0.0",
4 | "description": "ShutterSync aims to address the need for professional photographers to efficiently share and collaborate on their work with clients, enhancing client engagement and project management.",
5 | "main": "index.js",
6 | "repository": "https://github.com/ayequill/ShutterSync.git",
7 | "author": {
8 | "name": "Siaw A. Nicholas",
9 | "email": "siawnic.dev@gmail.com",
10 | "url": "https://siaw.dev"
11 | },
12 | "license": "MIT",
13 | "scripts": {
14 | "start": "node server/server.js",
15 | "dev": "nodemon -e js,json -w server -i node_modules --exec node --inspect server/server.js",
16 | "format": "prettier --config ./.prettierrc -w 'server/**/*.js' && git update-index --again",
17 | "prepare": "husky install",
18 | "cz": "cz",
19 | "test": "node --experimental-vm-modules node_modules/.bin/jest"
20 | },
21 | "type": "module",
22 | "keywords": [
23 | "react",
24 | "node",
25 | "express",
26 | "mongodb",
27 | "mern"
28 | ],
29 | "dependencies": {
30 | "@google-cloud/storage": "^7.6.0",
31 | "bcrypt": "^5.1.1",
32 | "body-parser": "^1.20.2",
33 | "cloudinary": "^1.41.0",
34 | "compression": "1.7.4",
35 | "cookie-parser": "^1.4.6",
36 | "cors": "^2.8.5",
37 | "crypto-js": "^4.1.1",
38 | "dotenv": "^16.3.1",
39 | "express": "^4.18.2",
40 | "express-jwt": "^8.4.1",
41 | "helmet": "^7.0.0",
42 | "jsonwebtoken": "^9.0.2",
43 | "lodash": "^4.17.21",
44 | "mongodb": "^6.2.0",
45 | "mongoose": "^8.0.0",
46 | "morgan": "^1.10.0",
47 | "multer": "^1.4.5-lts.1",
48 | "nanoid": "^5.0.3",
49 | "nodemailer": "^6.9.7",
50 | "nodemon": "^3.0.1",
51 | "swagger-jsdoc": "^6.2.8",
52 | "swagger-ui-express": "^5.0.0",
53 | "usehooks-ts": "^2.9.1",
54 | "uuid": "^9.0.1",
55 | "yamljs": "^0.3.0"
56 | },
57 | "devDependencies": {
58 | "@commitlint/cli": "^17.4.2",
59 | "@commitlint/config-conventional": "^17.4.2",
60 | "babel-cli": "^6.26.0",
61 | "babel-preset-env": "^1.7.0",
62 | "cz-conventional-changelog": "3.3.0",
63 | "eslint": "^8.53.0",
64 | "eslint-config-airbnb": "^19.0.4",
65 | "eslint-config-prettier": "^8.4.0",
66 | "eslint-plugin-import": "^2.25.4",
67 | "eslint-plugin-jsx-a11y": "^6.5.1",
68 | "eslint-plugin-prettier": "^5.0.1",
69 | "eslint-plugin-react": "^7.28.0",
70 | "eslint-plugin-react-hooks": "^4.3.0",
71 | "eslint-plugin-simple-import-sort": "^9.0.0",
72 | "husky": "^8.0.3",
73 | "identity-obj-proxy": "^3.0.0",
74 | "jest": "^29.7.0",
75 | "jest-environment-jsdom": "^29.7.0",
76 | "prettier": "^3.0.3",
77 | "rollup": "^3.10.1",
78 | "superagent": "^8.1.2",
79 | "supertest": "^6.3.3"
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/public/images/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ayequill/ShutterSync/dd711d5e72cd5eabbdd53fc44befc1f0fdabb225/public/images/.gitkeep
--------------------------------------------------------------------------------
/server/__tests__/albums.test.js:
--------------------------------------------------------------------------------
1 | import request from 'supertest';
2 | import app from '../app.js';
3 | import Album from '../models/album.model.js';
4 | import mongoose from 'mongoose';
5 |
6 | process.env.NODE_ENV = 'test';
7 | const API_KEY = process.env.KEY;
8 |
9 | const userID = '6563c29c8e92f08eb9db20df';
10 | const baseURL = `/api/users/${userID}/albums`;
11 |
12 | describe('Test Albums', () => {
13 | let req;
14 | let albumID;
15 | beforeEach(async () => {
16 | mongoose.connect(process.env.TEST_DB, {
17 | useNewUrlParser: true,
18 | useUnifiedTopology: true,
19 | });
20 |
21 | req = request(app);
22 | // await Album.deleteMany({});
23 | });
24 | afterAll(async () => {
25 | await Album.deleteMany({});
26 | await mongoose.connection.close();
27 | });
28 |
29 | test('/GET Get All Albums from user', async () => {
30 | const res = await req.get(baseURL).set('x-api-key', API_KEY);
31 | expect(res.status).toBe(200);
32 | expect(res.body).toBeInstanceOf(Array);
33 | }, 10000);
34 |
35 | test('/POST Create an album', async () => {
36 | const album = { name: 'Test Album' };
37 | const res = await req.post(baseURL).send(album).set('x-api-key', API_KEY);
38 |
39 | expect(res.status).toBe(200);
40 | expect(res.body.name).toBe('Test Album');
41 | albumID = res.body._id;
42 | });
43 |
44 | test('/GET Get a single album from without api header', async () => {
45 | const res = await req.get(`${baseURL}/${albumID}`);
46 |
47 | expect(res.status).toBe(401);
48 | });
49 |
50 | test('/GET Get a single album with api key attached', async () => {
51 | const res = await req
52 | .get(`${baseURL}/${albumID}`)
53 | .set('x-api-key', API_KEY);
54 |
55 | expect(res.status).toBe(200);
56 | });
57 |
58 | test('/GET Get a single album with invalid id', async () => {
59 | const res = await req.get(`${baseURL}/123`).set('x-api-key', API_KEY);
60 |
61 | expect(res.status).toBe(500);
62 | });
63 |
64 | test('/GET Get a single album with valid id', async () => {
65 | const res = await req
66 | .get(`${baseURL}/${albumID}`)
67 | .set('x-api-key', API_KEY);
68 |
69 | expect(res.status).toBe(200);
70 | });
71 |
72 | test('/PUT Update a single album with invalid id', async () => {
73 | const res = await req.put(`${baseURL}/123`).set('x-api-key', API_KEY);
74 |
75 | expect(res.status).toBe(500);
76 | });
77 |
78 | test('/PUT Update a single album with valid id', async () => {
79 | const res = await req
80 | .put(`${baseURL}/${albumID}`)
81 | .set('x-api-key', API_KEY)
82 | .send({ name: 'Updated Album' });
83 | expect(res.status).toBe(200);
84 | expect(res.body?.message).toBe('Album updated successfully');
85 | });
86 |
87 | test('/DELETE Delete a single album with invalid id', async () => {
88 | const res = await req.delete(`${baseURL}/123`).set('x-api-key', API_KEY);
89 |
90 | expect(res.status).toBe(500);
91 | });
92 |
93 | test('/DELETE Delete a single album with valid id', async () => {
94 | const res = await req
95 | .delete(`${baseURL}/${albumID}`)
96 | .set('x-api-key', API_KEY);
97 |
98 | expect(res.status).toBe(200);
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import bodyParser from 'body-parser';
3 | import cookieParser from 'cookie-parser';
4 | import cors from 'cors';
5 | import helmet from 'helmet';
6 | import swaggerUi from 'swagger-ui-express';
7 | import morgan from 'morgan';
8 | import userRoutes from './routes/user.routes.js';
9 | import authRoutes from './routes/auth.routes.js';
10 | import API_CHECK from './middlewares/api.key.controller.js';
11 | import multer from 'multer';
12 | import yaml from 'yamljs';
13 |
14 | // create express app
15 | const app = express();
16 |
17 | const storage = multer.diskStorage({
18 | destination: (req, file, cb) => {
19 | cb(null, 'public/images/');
20 | },
21 | filename: function (req, file, cb) {
22 | cb(null, Date.now() + '-' + file.originalname);
23 | },
24 | });
25 |
26 | const fileFilter = (req, file, cb) => {
27 | if (file.mimetype.startsWith('image/')) {
28 | cb(null, true);
29 | } else {
30 | cb({ error: 'File type not supported' }, false);
31 | }
32 | };
33 |
34 | if (process.env.NODE_ENV !== 'test') {
35 | app.use(morgan('combined'));
36 | }
37 | const yamlSpec = yaml.load('./server/docs/swagger.yaml');
38 | app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(yamlSpec));
39 | export const upload = multer({ storage, fileFilter });
40 | // parse requests of content-type - application/json
41 | app.use(bodyParser.json());
42 | // parse requests of content-type - application/x-www-form-urlencoded
43 | app.use(bodyParser.urlencoded({ extended: true }));
44 | // parse cookies
45 | app.use(cookieParser());
46 | // use helmet
47 | app.use(helmet());
48 | // use cors
49 | app.use(
50 | cors({
51 | origin: [
52 | 'http://localhost:5173',
53 | 'http://localhost:3000',
54 | 'https://www.shuttersync.live',
55 | 'https://shuttersync.live',
56 | 'https://shuttersync.vercel.app/',
57 | ],
58 | methods: 'GET,POST,DELETE,PUT,OPTIONS',
59 | credentials: true,
60 | })
61 | );
62 | // app.use(API_CHECK);
63 | app.use('/', userRoutes);
64 | app.use('/', authRoutes);
65 |
66 | app.use((err, req, res, next) => {
67 | if (err.name === 'unauthorizedError') {
68 | res.status(401).json({
69 | error: `${err.name}: ${err.message}`,
70 | });
71 | } else if (err) {
72 | res.status(400).json({
73 | error: `${err.name}: ${err.message}`,
74 | });
75 | }
76 | });
77 |
78 | export default app;
79 |
--------------------------------------------------------------------------------
/server/controllers/album.controller.js:
--------------------------------------------------------------------------------
1 | import Album from '../models/album.model.js';
2 | import User from '../models/user.model.js';
3 | import Photo from '../models/photo.model.js';
4 | import { v2 as cloudinary } from 'cloudinary';
5 |
6 | cloudinary.config({
7 | cloud_name: 'dzpjlfcrq',
8 | api_key: process.env.CLOUDINARY_API_KEY,
9 | api_secret: process.env.CLOUDINARY_API_SECRET,
10 | });
11 |
12 | // Create an album
13 | const createUserAlbum = async (req, res) => {
14 | try {
15 | const user = req.profile;
16 | const newAlbum = new Album({
17 | name: req.body.name,
18 | });
19 | user.albums.push(newAlbum);
20 | await newAlbum.save();
21 | await user.save();
22 | res.json(newAlbum);
23 | } catch (e) {
24 | res.status(500).json({
25 | error: 'Internal Server Error',
26 | });
27 | console.log(e);
28 | }
29 | };
30 |
31 | // Get all albums a user has
32 | const getUserAlbums = async (req, res) => {
33 | try {
34 | const userAlbums = await User.findById(req.profile._id.toString())
35 | .populate({
36 | path: 'albums',
37 | options: { sort: { createdAt: -1 } },
38 | populate: {
39 | path: 'photos',
40 | },
41 | })
42 | .exec();
43 | res.json(userAlbums.albums);
44 | } catch (e) {
45 | return res.status(400).json({
46 | error: e,
47 | });
48 | }
49 | };
50 |
51 | // Get particular album id
52 | const albumByID = async (req, res, next, id) => {
53 | try {
54 | const album = await Album.findById(id).populate('photos');
55 | if (!album) {
56 | return res.status(404).json({
57 | error: 'Album not found',
58 | });
59 | }
60 | req.album = album;
61 | next();
62 | } catch (e) {
63 | return res.status(500).json({
64 | error: 'Internal Server Error',
65 | });
66 | }
67 | };
68 |
69 | // Get a particular album
70 | const getUserAlbum = (req, res) => {
71 | try {
72 | const album = req.album;
73 | return res.json(album);
74 | } catch (e) {
75 | return res.status(400).json({
76 | error: e,
77 | });
78 | }
79 | };
80 |
81 | const deleteUserAlbum = async (req, res) => {
82 | try {
83 | const { profile, album } = req;
84 | await User.findByIdAndUpdate(
85 | profile._id,
86 | { $pull: { albums: album._id.toString() } },
87 | { new: true }
88 | );
89 | const photos = await album.populate('photos');
90 |
91 | const public_ids = photos.photos.map((photo) => photo.public_id);
92 | if (public_ids.length > 0) {
93 | await cloudinary.api
94 | .delete_resources_by_tag(album._id.toString())
95 | .then((result) => {
96 | console.log(result);
97 | });
98 | }
99 | await Photo.deleteMany({ album: album._id });
100 | await Album.deleteOne(album._id);
101 | res.json({
102 | message: 'Album successfully deleted',
103 | });
104 | } catch (e) {
105 | return res.status(404).json({
106 | error: e,
107 | });
108 | }
109 | };
110 |
111 | const updateUserAlbum = async (req, res) => {
112 | try {
113 | let { album } = req;
114 | Object.assign(album, req.body);
115 | const updatedAlbum = await album.save();
116 | return res.json({
117 | ...updatedAlbum._doc,
118 | message: 'Album updated successfully',
119 | });
120 | } catch (e) {
121 | return res.status(400).json({
122 | error: e.message,
123 | });
124 | }
125 | };
126 |
127 | const getAlbum = async (req, res) => {
128 | try {
129 | const albumId = req.query.id;
130 |
131 | const album = await Album.findById(albumId).populate('photos');
132 |
133 | if (!album)
134 | return res.status(404).json({
135 | error: 'Album not found',
136 | });
137 |
138 | return res.status(200).json(album);
139 | } catch (e) {
140 | return res.status(400).json({
141 | error: e.message,
142 | message: 'Failed to fetch album',
143 | });
144 | }
145 | };
146 |
147 | const checkAlbumLock = async (req, res) => {
148 | try {
149 | const { password } = req.body;
150 | const { id } = req.query;
151 | console.log(password);
152 | console.log(id, password);
153 |
154 | const album = await Album.findById(id);
155 |
156 | if (password !== album?.password) {
157 | return res.status(401).json({ error: 'User not authorized' });
158 | }
159 | return res.status(200).json({ success: 'Allowed' });
160 | } catch (e) {
161 | console.log(e);
162 | return res.status(400).json({ error: e });
163 | }
164 | };
165 |
166 | export default {
167 | checkAlbumLock,
168 | createUserAlbum,
169 | getUserAlbum,
170 | albumByID,
171 | getUserAlbums,
172 | deleteUserAlbum,
173 | updateUserAlbum,
174 | getAlbum,
175 | };
176 |
--------------------------------------------------------------------------------
/server/controllers/auth.controller.js:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import { expressjwt } from 'express-jwt';
3 | import dotenv from 'dotenv';
4 | import User from '../models/user.model.js';
5 | import dbErrorHandler from '../helpers/dbErrorHandler.js';
6 | import { sendMail, resetPasswordHTML } from '../helpers/sendMail.js';
7 | import { v4 } from 'uuid';
8 |
9 | dotenv.config();
10 |
11 | const signIn = async (req, res) => {
12 | try {
13 | const { email, password } = req.body;
14 | const user = await User.findOne({ email });
15 | if (!user) {
16 | return res.status(404).json({
17 | error: 'User not found. Please sign up!',
18 | status: 404,
19 | });
20 | }
21 | if (!user.authenticate(password)) {
22 | return res.status(400).json({
23 | error: "Email or password don't match.",
24 | status: 400,
25 | });
26 | }
27 | const token = jwt.sign({ _id: user._id }, process.env.JWT_SECRET);
28 | res.cookie('__shuttersync_session', token, {
29 | httpOnly: true,
30 | maxAge: 9999,
31 | });
32 |
33 | return res.status(201).json({
34 | token,
35 | user: {
36 | _id: user._id,
37 | name: user.name,
38 | email: user.email,
39 | },
40 | });
41 | } catch (e) {
42 | return res.status(401).json({
43 | error: 'Error signing in. Try again.',
44 | });
45 | }
46 | };
47 |
48 | const signout = (req, res) => {
49 | res.clearCookie('t');
50 | return res.status(200).json({
51 | message: 'Signed out',
52 | });
53 | };
54 |
55 | const requireSignIn = expressjwt({
56 | secret: process.env.JWT_SECRET,
57 | userProperty: 'auth',
58 | algorithms: ['HS256'],
59 | });
60 |
61 | const isAuthorized = (req, res, next) => {
62 | const authorized = req.profile && req.auth && req.profile._id == req.auth._id;
63 | if (!authorized) {
64 | return res.status(403).json({
65 | error: 'User is not authorized',
66 | });
67 | }
68 | next();
69 | };
70 |
71 | const confirmEmail = async (req, res) => {
72 | try {
73 | const token = req.query.token;
74 | const user = await User.findOne({ token });
75 | if (!user) {
76 | return res.status(401).json({
77 | error: 'Invalid token',
78 | });
79 | }
80 | user.verified = true;
81 | await user.save();
82 | return res.status(200).json({
83 | message: 'Email confirmed',
84 | user: {
85 | _id: user._id,
86 | name: user.name,
87 | email: user.email,
88 | },
89 | });
90 | } catch (e) {
91 | return res.status(401).json({
92 | error: 'Invalid token',
93 | });
94 | }
95 | };
96 |
97 | const forgotPassword = async (req, res) => {
98 | try {
99 | const { email } = req.body;
100 | if (!email) {
101 | return res.status(400).json({
102 | error: 'Email is required',
103 | });
104 | }
105 |
106 | const user = await User.findOne({ email });
107 |
108 | if (!user) {
109 | return res.status(400).json({
110 | error: 'User not found',
111 | });
112 | }
113 |
114 | const token = v4();
115 | user.token = token;
116 | await user.save();
117 |
118 | const subject = 'Reset your password';
119 | const link = `https://shuttersync.live/forgot-password/${token}`;
120 | await sendMail(email, subject, resetPasswordHTML(user.name, link));
121 | return res.status(200).json({
122 | message: 'Email sent',
123 | });
124 | } catch (e) {
125 | return res.status(400).json({
126 | error: dbErrorHandler.getErrorMessage(e),
127 | });
128 | }
129 | };
130 |
131 | const resetPassword = async (req, res) => {
132 | try {
133 | const { email, password, newPassword } = req.body;
134 | if (!email || !password || !newPassword) {
135 | return res.status(400).json({
136 | error: 'Email, password and new password are required',
137 | });
138 | }
139 | const user = await User.findOne({ email });
140 | if (!user) {
141 | return res.status(400).json({
142 | error: 'You are not registered. Please sign up!',
143 | });
144 | }
145 | user.password = newPassword;
146 | await user.save();
147 | return res.status(200).json({
148 | message: 'Password updated!',
149 | });
150 | } catch (e) {
151 | return res.status(400).json({
152 | error: dbErrorHandler.getErrorMessage(e),
153 | });
154 | }
155 | };
156 |
157 | export default {
158 | signIn,
159 | signout,
160 | requireSignIn,
161 | resetPassword,
162 | isAuthorized,
163 | confirmEmail,
164 | forgotPassword,
165 | };
166 |
--------------------------------------------------------------------------------
/server/controllers/photo.controller.js:
--------------------------------------------------------------------------------
1 | import Photo from '../models/photo.model.js';
2 | import Album from '../models/album.model.js';
3 | import { upload } from '../app.js';
4 | import multer from 'multer';
5 | import { v2 as cloudinary } from 'cloudinary';
6 | import dotenv from 'dotenv';
7 | import { Storage, TransferManager } from '@google-cloud/storage';
8 | import path from 'path';
9 | dotenv.config();
10 |
11 | const { GOOGLE_CLIENT_EMAIL, GOOGLE_PRIVATE_KEY, GOOGLE_PROJECT_ID } =
12 | process.env;
13 |
14 | const GOOGLE_PRIVATE_KEY_NEWLINE = GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n');
15 |
16 | cloudinary.config({
17 | cloud_name: 'dzpjlfcrq',
18 | api_key: process.env.CLOUDINARY_API_KEY,
19 | api_secret: process.env.CLOUDINARY_API_SECRET,
20 | });
21 |
22 | const bucketName = 'shuttersync-storage';
23 |
24 | const googleStorage = new Storage({
25 | projectId: GOOGLE_PROJECT_ID,
26 | credentials: {
27 | client_email: GOOGLE_CLIENT_EMAIL,
28 | private_key: GOOGLE_PRIVATE_KEY_NEWLINE,
29 | },
30 | });
31 |
32 | const bucket = googleStorage.bucket(bucketName);
33 | const transferManager = new TransferManager(bucket);
34 |
35 | const addPhoto = async (req, res) => {
36 | try {
37 | const { album } = req;
38 | let photoObjs = { album: album._id };
39 |
40 | // Use multer middleware for handling file upload
41 | upload.array('photos')(req, res, async (err) => {
42 | try {
43 | if (err instanceof multer.MulterError) {
44 | return res.status(400).json({
45 | error: err.message,
46 | });
47 | } else if (err) {
48 | return res.status(400).json({
49 | error: err.message,
50 | });
51 | }
52 |
53 | // Upload image to cloudinary
54 | const albumFolder = album._id.toString();
55 | // const filePaths = req.files.map((file) => file.path);
56 |
57 | // await transferManager.uploadManyFiles(filePaths, albumFolder);
58 |
59 | // const publicUrls = req.files.map((file) => {
60 | // const photoFileName = path.basename(file.path);
61 | // return `https://storage.googleapis.com/${bucketName}/public/images/${photoFileName}`;
62 | // });
63 | const uploadPromises = req.files.map(async (file, index) => {
64 | return new Promise((resolve, reject) => {
65 | cloudinary.uploader.upload(
66 | file.path,
67 | {
68 | tags: albumFolder,
69 | transformation: { width: 800, fetch_format: 'auto' },
70 | },
71 | (error, result) => {
72 | if (error) {
73 | reject(error);
74 | } else {
75 | resolve({
76 | ...photoObjs,
77 | imageUrl: result.secure_url,
78 | size: result.bytes,
79 | public_id: result.public_id,
80 | name: result.original_filename,
81 | created_at: result.created_at,
82 | storageUrl: result.secure_url,
83 | });
84 | }
85 | }
86 | );
87 | });
88 | });
89 | const uploadedPhotos = await Promise.all(uploadPromises);
90 | const photos = await Photo.insertMany(uploadedPhotos);
91 | album.photos.push(...photos);
92 | await album.save();
93 | return res.json(photos);
94 | } catch (e) {
95 | console.log(e);
96 | return res.status(500).json({
97 | error: e.message,
98 | });
99 | }
100 | });
101 | } catch (e) {
102 | console.log(e);
103 | return res.status(500).json({
104 | error: e.message,
105 | });
106 | }
107 | };
108 |
109 | const addSinglePhoto = async (req, res) => {
110 | try {
111 | // const { album } = req;
112 | // let photoObjs = { album: album._id };
113 |
114 | // Use multer middleware for handling file upload
115 | upload.single('photo')(req, res, async (err) => {
116 | try {
117 | if (err instanceof multer.MulterError) {
118 | return res.status(400).json({
119 | error: err.message,
120 | });
121 | } else if (err) {
122 | return res.status(400).json({
123 | error: err.message,
124 | });
125 | }
126 |
127 | // Upload image to cloudinary
128 | // const albumFolder = album._id.toString();
129 | const filePath = req.file.path;
130 | console.log(req.file);
131 |
132 | // await transferManager.uploadFile(filePath, albumFolder);
133 |
134 | // const publicUrl = `https://storage.googleapis.com/${bucketName}/public/images/${path.basename(
135 | // filePath
136 | // )}`;
137 |
138 | const uploadPromise = new Promise((resolve, reject) => {
139 | cloudinary.uploader.upload(
140 | filePath,
141 | {
142 | transformation: { width: 800, fetch_format: 'auto' },
143 | },
144 | (error, result) => {
145 | if (error) {
146 | reject(error);
147 | } else {
148 | resolve({
149 | imageUrl: result.secure_url,
150 | size: result.bytes,
151 | public_id: result.public_id,
152 | name: result.original_filename,
153 | created_at: result.created_at,
154 | storageUrl: result.secure_url,
155 | });
156 | }
157 | }
158 | );
159 | });
160 | const uploadedPhoto = await uploadPromise;
161 | const album = await Album.create({ name: 'New Album' });
162 | const photo = await Photo.create(uploadedPhoto);
163 | album.photos.push(photo);
164 | await album.save();
165 | return res.json(photo);
166 | } catch (e) {
167 | console.log(e);
168 | return res.status(500).json({
169 | error: e.message,
170 | });
171 | }
172 | });
173 | } catch (e) {
174 | console.log(e);
175 | return res.status(500).json({
176 | error: e.message,
177 | });
178 | }
179 | };
180 |
181 | const photoById = async (req, res, next, id) => {
182 | try {
183 | const photo = await Photo.findById(id).populate('album').exec();
184 | if (!photo) {
185 | return res.status(404).json({
186 | error: 'Photo not found',
187 | });
188 | }
189 | req.photo = photo;
190 | next();
191 | } catch (e) {
192 | return res.status(500).json({
193 | error: 'Internal server error',
194 | });
195 | }
196 | };
197 |
198 | const getPhoto = (req, res) => {
199 | try {
200 | res.json(req.photo);
201 | } catch (e) {
202 | return res.json(400).json({
203 | error: e,
204 | });
205 | }
206 | };
207 |
208 | const deletePhoto = async (req, res) => {
209 | try {
210 | const { photo } = req;
211 | // const delPhoto = await photo.remove()
212 | const deletedAlbum = await Album.findByIdAndUpdate(
213 | photo.album._id.toString(),
214 | { $pull: { photos: photo._id.toString() } },
215 | { new: true }
216 | );
217 | await Photo.deleteOne(photo);
218 | cloudinary.api.delete_resources([photo.public_id], {
219 | type: 'upload',
220 | resource_type: 'image',
221 | });
222 | // .then((data) => res.status(201).json({ data }));
223 | return res
224 | .status(201)
225 | .json({ album: deletedAlbum, message: 'Photo deleted successfully' });
226 | } catch (e) {
227 | return res.status(400).json({
228 | error: e,
229 | });
230 | }
231 | };
232 |
233 | const updatePhoto = async (req, res) => {
234 | try {
235 | const { photo } = req;
236 | console.log(photo);
237 | photo.imageUrl = req.body.imageUrl;
238 | photo.caption = req?.body?.caption;
239 | await photo.save();
240 | res.json(photo);
241 | } catch (e) {
242 | return res.status(400).json({
243 | error: e,
244 | });
245 | }
246 | };
247 |
248 | export default {
249 | addPhoto,
250 | photoById,
251 | getPhoto,
252 | deletePhoto,
253 | updatePhoto,
254 | addSinglePhoto,
255 | };
256 |
--------------------------------------------------------------------------------
/server/controllers/user.controller.js:
--------------------------------------------------------------------------------
1 | import extend from 'lodash/extend.js';
2 | import User from '../models/user.model.js';
3 | import { v4 as genId } from 'uuid';
4 | import dbErrorHandler from '../helpers/dbErrorHandler.js';
5 | import { sendMail, html } from '../helpers/sendMail.js';
6 |
7 | const create = async (req, res, next) => {
8 | // Get user details from req.body
9 | const { name, email, password } = req.body;
10 | if (!name || !email || !password) {
11 | return res.status(400).json({
12 | error: 'Name, email and password are required',
13 | });
14 | }
15 |
16 | // Check if user already exists
17 | const userExist = await User.findOne({ email });
18 | if (userExist) {
19 | return res.status(400).json({
20 | error: `${userExist.email} is already taken`,
21 | });
22 | }
23 |
24 | const user = new User({ name, email, password, token: genId() });
25 | try {
26 | await user.save();
27 | const subject = 'Welcome to ShutterSync 😎';
28 | const link = `https://shuttersync.live/verify/${user.token}`;
29 | await sendMail(email, subject, html(name, link));
30 | return res.status(200).json({
31 | message: 'Successfully signed up',
32 | });
33 | } catch (e) {
34 | return res.status(400).json({
35 | error: dbErrorHandler.getErrorMessage(e),
36 | });
37 | }
38 | };
39 |
40 | const list = async (req, res) => {
41 | try {
42 | const users = await User.find().select(
43 | 'name email created_at albums verified'
44 | );
45 | res.json(users);
46 | } catch (e) {
47 | return res.status(400).json({
48 | error: dbErrorHandler.getErrorMessage(e),
49 | });
50 | }
51 | };
52 |
53 | const userByID = async (req, res, next, id) => {
54 | try {
55 | const user = await User.findById(id);
56 | if (!user) {
57 | return res.status(400).json({
58 | error: 'User not found!',
59 | });
60 | }
61 | req.profile = user;
62 | next();
63 | } catch (e) {
64 | return res.status(400).json({
65 | error: 'Could not retrieve user',
66 | });
67 | }
68 | };
69 |
70 | const read = (req, res) => {
71 | req.profile.hashed_password = undefined;
72 | req.profile.salt = undefined;
73 | return res.json(req.profile);
74 | };
75 |
76 | const update = async (req, res) => {
77 | try {
78 | let user = req.profile;
79 | user = extend(user, req.body);
80 | user.updated_at = Date.now();
81 | await user.save();
82 | user.hashed_password = undefined;
83 | user.salt = undefined;
84 | res.status(201).json(user);
85 | } catch (e) {
86 | return res.status(400).json({
87 | error: dbErrorHandler.getErrorMessage(e),
88 | });
89 | }
90 | };
91 |
92 | const remove = async (req, res) => {
93 | try {
94 | const user = req.profile;
95 | await User.deleteOne(user);
96 | user.hashed_password = undefined;
97 | user.salt = undefined;
98 | res.status(201).json(user);
99 | } catch (e) {
100 | return res.status(400).json({
101 | error: dbErrorHandler.getErrorMessage(e),
102 | });
103 | }
104 | };
105 |
106 | export default {
107 | create,
108 | list,
109 | userByID,
110 | read,
111 | update,
112 | remove,
113 | };
114 |
--------------------------------------------------------------------------------
/server/helpers/dbErrorHandler.js:
--------------------------------------------------------------------------------
1 | /* DB Error Handling */
2 |
3 | const getErrorMessage = (error) => {
4 | let message = '';
5 | if (error.code) {
6 | switch (error.code) {
7 | case 11000:
8 | case 11001:
9 | message = getUniqueErrorMessage(error);
10 | break;
11 | default:
12 | message = 'Something went wrong';
13 | }
14 | } else {
15 | for (const errName in error.errors) {
16 | if (error.errors[errName].message)
17 | message = error.errors[errName].message;
18 | }
19 | }
20 | return message;
21 | };
22 |
23 | export default { getErrorMessage };
24 |
25 | const getUniqueErrorMessage = (error) => {
26 | let output;
27 | try {
28 | const fieldName = error.message.substring(
29 | error.message.lastIndexOf('.$') + 2,
30 | error.message.lastIndexOf('_1')
31 | );
32 | output = `${
33 | fieldName.charAt(0).toLocaleLowerCase() + fieldName.slice(1)
34 | } already exists`;
35 | } catch (e) {
36 | output = 'Unique field already exists';
37 | }
38 | return output;
39 | };
40 |
--------------------------------------------------------------------------------
/server/helpers/sendMail.js:
--------------------------------------------------------------------------------
1 | import nodemailer from 'nodemailer';
2 | import dotenv from 'dotenv';
3 | dotenv.config();
4 |
5 | const { MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PWD, MAIL_DOMAIN } = process.env;
6 |
7 | const html = (name, link) => {
8 | return `
9 |
10 |
11 |
12 |
13 |
14 | Account Registration
15 |
54 |
55 |
56 |
57 |
Welcome, ${name}!
58 |
Thank you for registering with our app. We're excited to have you on board.
59 |
To get started, please click the confirmation link below:
60 |
Confirm Your Email
61 |
62 |
63 |
64 |
65 | `;
66 | };
67 |
68 | const resetPasswordHTML = (name, link) => {
69 | return `
70 |
71 |
72 |
73 | Reset Password
74 |
113 |
114 |
115 |
116 |
Hi, ${name}!
117 |
We received a request to reset your password. If you didn't make the request, please ignore this email.
118 |
To reset your password, please click the button below:
119 |
Reset Password
120 |
121 |
122 |
123 |
124 | `;
125 | };
126 |
127 | const sendMail = async (to, subject, html) => {
128 | try {
129 | const transporter = nodemailer.createTransport({
130 | host: MAIL_HOST,
131 | port: MAIL_PORT,
132 | requireTLS: true,
133 | auth: {
134 | user: MAIL_USER,
135 | pass: MAIL_PWD,
136 | },
137 | });
138 |
139 | const info = await transporter.sendMail({
140 | from: `noreply@${MAIL_DOMAIN}`,
141 | to,
142 | subject,
143 | html,
144 | });
145 |
146 | return info;
147 | } catch (error) {
148 | console.log(error);
149 | }
150 | };
151 |
152 | export { sendMail, html, resetPasswordHTML };
153 |
--------------------------------------------------------------------------------
/server/middlewares/api.key.controller.js:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 |
3 | dotenv.config();
4 |
5 | const getKeys = process.env.API_KEYS;
6 | const API_KEYS = getKeys ? getKeys.split(',') : ['1234567890'];
7 |
8 | const checkApiKey = (req, res, next) => {
9 | const apiKey = req.get('x-api-key');
10 | if (API_KEYS.includes(apiKey)) {
11 | next();
12 | } else {
13 | res.status(401).json({
14 | error: 'Unauthorized',
15 | });
16 | }
17 | };
18 |
19 | export default checkApiKey;
20 |
--------------------------------------------------------------------------------
/server/models/album.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const ALBUM_SCHEMA = new mongoose.Schema(
4 | {
5 | name: {
6 | type: String,
7 | trim: true,
8 | required: 'Album needs a name!',
9 | unique: 'Album exist!',
10 | },
11 | photos: [
12 | {
13 | type: mongoose.Schema.Types.ObjectId,
14 | ref: 'Photo',
15 | },
16 | ],
17 | published: {
18 | type: Boolean,
19 | default: false,
20 | },
21 | locked: {
22 | type: Boolean,
23 | default: false,
24 | },
25 | password: {
26 | type: String,
27 | trim: true,
28 | default: '',
29 | },
30 | },
31 | { timestamps: true }
32 | );
33 |
34 | export default mongoose.model('Album', ALBUM_SCHEMA);
35 |
--------------------------------------------------------------------------------
/server/models/photo.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const PHOTOS_SCHEMA = new mongoose.Schema(
4 | {
5 | album: {
6 | type: mongoose.Schema.Types.ObjectId,
7 | ref: 'Album',
8 | },
9 | imageUrl: {
10 | type: String,
11 | required: true,
12 | },
13 | storageUrl: {
14 | type: String,
15 | required: true,
16 | },
17 | caption: String,
18 | size: String,
19 | public_id: String,
20 | name: String,
21 | },
22 | { timestamps: true }
23 | );
24 |
25 | export default mongoose.model('Photo', PHOTOS_SCHEMA);
26 |
--------------------------------------------------------------------------------
/server/models/user.model.js:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 | import sha1 from 'crypto-js/sha1.js';
3 | import Album from './album.model.js';
4 | // Create a schema for user
5 | const USER_SCHEMA = new mongoose.Schema({
6 | name: {
7 | type: String,
8 | trim: true,
9 | required: 'Name is required',
10 | },
11 | email: {
12 | type: String,
13 | trim: true,
14 | unique: 'Email already taken',
15 | match: [/.+\@.+\..+/, 'Please fill a valid email address'],
16 | required: 'Email is required',
17 | },
18 | created_at: {
19 | type: Date,
20 | default: Date.now,
21 | },
22 | updated_at: Date,
23 | hashed_password: {
24 | type: String,
25 | required: 'Password is required',
26 | },
27 | salt: String,
28 | avatar: String,
29 | albums: [
30 | {
31 | type: mongoose.Schema.Types.ObjectId,
32 | ref: 'Album',
33 | },
34 | ],
35 | verified: {
36 | type: Boolean,
37 | default: false,
38 | },
39 | token: String,
40 | });
41 |
42 | USER_SCHEMA.virtual('password')
43 | .set(async function (password) {
44 | this._password = password;
45 | this.salt = this.makeSalt();
46 | this.hashed_password = sha1(this._password, this.salt);
47 |
48 | console.log(this.salt, this.hashed_password);
49 | })
50 | .get(function () {
51 | return this._password;
52 | });
53 |
54 | USER_SCHEMA.path('hashed_password').validate(function (v) {
55 | if (this._password && this._password.length < 6) {
56 | this.invalidate('password', 'Password must be at least 6 characters.');
57 | }
58 | if (this.isNew && !this._password) {
59 | this.invalidate('password', 'Password is required');
60 | }
61 | }, null);
62 |
63 | USER_SCHEMA.methods = {
64 | authenticate(plainText) {
65 | const hashedInput = sha1(plainText, this.salt);
66 | return hashedInput.toString() === this.hashed_password;
67 | },
68 | makeSalt() {
69 | return `${Math.round(new Date().valueOf() * Math.random())}`;
70 | },
71 | };
72 |
73 | export default mongoose.model('User', USER_SCHEMA);
74 |
--------------------------------------------------------------------------------
/server/routes/auth.routes.js:
--------------------------------------------------------------------------------
1 | /* Authentication middlewares */
2 | import express from 'express';
3 | import userAuth from '../controllers/auth.controller.js';
4 |
5 | const router = express.Router();
6 |
7 | router.route('/auth/signin').post(userAuth.signIn);
8 | router.route('/auth/signout').get(userAuth.signout);
9 | router.route('/auth/verify').get(userAuth.confirmEmail);
10 | router.route('/auth/reset').put(userAuth.resetPassword);
11 | router.route('/auth/reset').post(userAuth.forgotPassword);
12 |
13 | export default router;
14 |
--------------------------------------------------------------------------------
/server/routes/user.routes.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import userCtrl from '../controllers/user.controller.js';
3 | import userAuth from '../controllers/auth.controller.js';
4 | import albumCtrl from '../controllers/album.controller.js';
5 | import photoCtrl from '../controllers/photo.controller.js';
6 |
7 | const router = express.Router();
8 |
9 | router.route('/api/users').post(userCtrl.create);
10 | router.route('/api/users').get(userCtrl.list);
11 |
12 | router
13 | .route('/api/users/:userId')
14 | .get(userAuth.requireSignIn, userCtrl.read)
15 | .put(userAuth.requireSignIn, userAuth.isAuthorized, userCtrl.update)
16 | .delete(userAuth.requireSignIn, userAuth.isAuthorized, userCtrl.remove);
17 |
18 | router
19 | .route('/api/users/:userId/albums')
20 | .post(albumCtrl.createUserAlbum)
21 | .get(albumCtrl.getUserAlbums);
22 |
23 | router
24 | .route('/api/users/:userId/albums/:albumId')
25 | .get(albumCtrl.getUserAlbum)
26 | .delete(albumCtrl.deleteUserAlbum)
27 | .put(albumCtrl.updateUserAlbum);
28 |
29 | router
30 | .route('/api/users/:userId/albums/:albumId/photo')
31 | .post(photoCtrl.addPhoto);
32 |
33 | router
34 | .route('/api/users/:userId/albums/:albumId/photo/:photoId')
35 | .delete(photoCtrl.deletePhoto);
36 |
37 | router
38 | .route('/api/photos/:photoId')
39 | .get(photoCtrl.getPhoto)
40 | .put(photoCtrl.updatePhoto)
41 | .delete(photoCtrl.deletePhoto);
42 |
43 | router
44 | .route('/api/albums')
45 | .get(albumCtrl.getAlbum)
46 | .post(albumCtrl.checkAlbumLock);
47 |
48 | router.route('/api/photo').post(photoCtrl.addSinglePhoto);
49 |
50 | router.param('userId', userCtrl.userByID);
51 | router.param('albumId', albumCtrl.albumByID);
52 | router.param('photoId', photoCtrl.photoById);
53 |
54 | export default router;
55 |
--------------------------------------------------------------------------------
/server/server.js:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 | import mongoose from 'mongoose';
3 | import app from './app.js';
4 |
5 | dotenv.config();
6 |
7 | const { DB_URI, PORT } = process.env;
8 |
9 | if (process.env.NODE_ENV == 'test') {
10 | DB_URI = process.env.TES_DB;
11 | }
12 |
13 | // Connecting to Mongo using mongoose
14 | mongoose
15 | .connect(DB_URI, {
16 | useNewUrlParser: true,
17 | useUnifiedTopology: true,
18 | })
19 | .then(() => {
20 | console.log('Connected to MongoDB!');
21 | })
22 | .catch((err) => {
23 | console.log('Error connecting to MongoDB');
24 | console.log(err);
25 | });
26 |
27 | // Close the db when node process stops
28 | process.on('SIGINT', () => {
29 | mongoose.connection
30 | .close(() => {
31 | process.exit(0);
32 | })
33 | .then(() => {
34 | console.log('Disconnected from MongoDB!');
35 | });
36 | });
37 | app.listen(PORT, () => {
38 | console.log(`Server started on port ${PORT}`);
39 | });
40 |
--------------------------------------------------------------------------------