├── .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 | [![CircleCI](https://dl.circleci.com/status-badge/img/gh/ayequill/ShutterSync/tree/main.svg?style=svg&circle-token=1ec3dcff5a5ca8d0559b6a48c3f5881a9706f76b)](https://dl.circleci.com/status-badge/redirect/gh/ayequill/ShutterSync/tree/main) 2 | 3 | [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://choosealicense.com/licenses/mit/) 4 | ![React](https://img.shields.io/badge/React-black.svg?logo=react) 5 | ![TypeScript](https://img.shields.io/badge/Typescript-black.svg?logo=typescript) 6 | ![Chakra](https://img.shields.io/badge/ChakraUi-black.svg?logo=chakraui) 7 | ![ExpressJs](https://img.shields.io/badge/Express-black.svg?logo=nodedotjs) 8 | ![MongoDB](https://img.shields.io/badge/MongoDB-black.svg?logo=mongodb) 9 | ![Heroku](https://img.shields.io/badge/Heroku-black.svg?logo=heroku) 10 | ![GCP](https://img.shields.io/badge/GoogleCloud-black.svg?logo=googlecloud) 11 | ![Jest](https://img.shields.io/badge/Jest-black.svg?logo=jest) 12 | 13 |
14 | Screenshot 1 15 | Screenshot 2 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 | [![CircleCI](https://dl.circleci.com/insights-snapshot/gh/ayequill/ShutterSync/main/lint_test/badge.svg?window=30d&circle-token=f3acaa2ba41774698c4036d6c77791cbb4c5c2a1)](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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 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 | 32 | 33 | 34 | 35 | 36 | View Album 37 | setShowShare(true)}>Share Album 38 | setShowQuickEdit(true)}>Edit Album 39 | setShowDelete(true)}>Delete Album 40 | 41 | 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 | {cover?.name} 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 | {album.photos[0].name} 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 | lock 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 | sign up illustration 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 | Error 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 | Customer 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 | Customer 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 | Customer 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 | 90 | 91 | 92 | 101 | ShutterSync Logo 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 |