├── client ├── src │ ├── vite-env.d.js │ ├── assets │ │ ├── fonts │ │ │ ├── Lobster.ttf │ │ │ ├── GreatVibes.otf │ │ │ ├── LobsterTwo-Bold.ttf │ │ │ └── LobsterTwo-Regular.ttf │ │ └── icons │ │ │ ├── PlusIcon.jsx │ │ │ ├── ArrowLeft.jsx │ │ │ ├── SendIcon.jsx │ │ │ ├── FolderIcon.jsx │ │ │ ├── CloseIcon.jsx │ │ │ ├── MapPinIcon.jsx │ │ │ ├── Sfw.jsx │ │ │ ├── CameraIcon.jsx │ │ │ ├── ChatIcon.jsx │ │ │ ├── PhotoIcon.jsx │ │ │ ├── CameraAltIcon.jsx │ │ │ ├── Nsfw2.jsx │ │ │ ├── Logo1.jsx │ │ │ ├── Nsfw.jsx │ │ │ └── Heart.jsx │ ├── services │ │ ├── TestService.js │ │ ├── LikeService.js │ │ ├── CommentService.js │ │ ├── FeedService.js │ │ ├── UserService.js │ │ ├── index.js │ │ ├── AuthService.js │ │ ├── AdminService.js │ │ ├── Api.js │ │ ├── CollectionService.js │ │ ├── PostService.js │ │ └── ProfileService.js │ ├── components │ │ ├── Map │ │ │ ├── SnapMap.jsx │ │ │ └── MapComponent.jsx │ │ ├── Search │ │ │ ├── ListEndCard.jsx │ │ │ └── UserCard.jsx │ │ ├── Feed │ │ │ ├── FeedWrapper.jsx │ │ │ └── Feed.jsx │ │ ├── Skeletons │ │ │ ├── AspectRatioPlaceholder.jsx │ │ │ └── ProfilePageSkeleton.jsx │ │ ├── Follows │ │ │ └── UserCard.jsx │ │ ├── Cropper │ │ │ ├── Cropper.jsx │ │ │ ├── ImageCropModalContent.jsx │ │ │ ├── Sliders.jsx │ │ │ └── ImageCrop.jsx │ │ ├── Layout │ │ │ ├── AcmeLogo.jsx │ │ │ ├── Appbar.jsx │ │ │ └── PageLayout.jsx │ │ ├── Dialog │ │ │ └── ConfirmationDialog.jsx │ │ ├── Comment │ │ │ └── WritePostComment.jsx │ │ ├── Post │ │ │ └── UploadImage.jsx │ │ └── Collection │ │ │ └── SelectCollectionItem.jsx │ ├── setupTests.js │ ├── reportWebVitals.js │ ├── hooks │ │ ├── useDebounce.js │ │ ├── useAuth.js │ │ ├── useAdmin.js │ │ ├── useAuthed.js │ │ ├── useNotifications.js │ │ └── useFeed.js │ ├── views │ │ ├── Landing.jsx │ │ ├── AdminDashboard.jsx │ │ ├── NotFound.jsx │ │ ├── VerifyEmail.jsx │ │ ├── PostLikes.jsx │ │ └── ProfileFollows.jsx │ ├── index.jsx │ ├── index.css │ ├── logo.svg │ ├── providers │ │ └── ImageCropProvider.jsx │ └── App.jsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── images │ │ ├── map │ │ │ └── marker-icon.png │ │ └── splash │ │ │ ├── splash-1.webp │ │ │ ├── splash-10.webp │ │ │ ├── splash-11.webp │ │ │ ├── splash-12.webp │ │ │ ├── splash-13.webp │ │ │ ├── splash-14.webp │ │ │ ├── splash-2.webp │ │ │ ├── splash-3.webp │ │ │ ├── splash-4.webp │ │ │ ├── splash-5.webp │ │ │ ├── splash-6.webp │ │ │ ├── splash-7.webp │ │ │ ├── splash-8.webp │ │ │ └── splash-9.webp │ ├── screenshots │ │ └── richer-install-mobile-screenshot.png │ ├── browserconfig.xml │ └── manifest.json ├── postcss.config.mjs ├── .env ├── vite.config.mjs ├── .gitignore ├── eslint.config.mjs ├── tailwind.config.mjs ├── index.html ├── package.json └── README.md ├── assets ├── logo.psd ├── logo_square.png ├── thumbnail-title.psd └── logo_with_wordmark.png ├── package.json ├── server ├── src │ ├── test.js │ ├── routes │ │ ├── test.routes.js │ │ ├── root.routes.js │ │ ├── share.routes.js │ │ ├── like.routes.js │ │ ├── comment.routes.js │ │ ├── feed.routes.js │ │ ├── post.routes.js │ │ ├── collection.routes.js │ │ ├── auth.routes.js │ │ ├── user.routes.js │ │ ├── admin.routes.js │ │ ├── index.js │ │ └── profile.routes.js │ ├── constants │ │ └── UserState.js │ ├── database │ │ ├── migrations │ │ │ ├── 20240514220131-add-userId-to-session.cjs │ │ │ ├── 20240614235109-add-push-token-to-user.cjs │ │ │ ├── 20240611183044-add-title-to-notification.cjs │ │ │ ├── 20240909220125-add-deletedAt-to-collections.cjs │ │ │ ├── 20240512183056-public-private-posts.cjs │ │ │ ├── 20240513001411-add-nsfw-tag-to-posts.cjs │ │ │ ├── 20240514193132-add-state-to-user.cjs │ │ │ ├── 20240428042107-allow-empty-post-titles.cjs │ │ │ ├── 20240428182127-update-postComment-to-type-TEXT.cjs │ │ │ ├── 20240615165532-move-push-token-to-session.cjs │ │ │ ├── 20240412165948-add-follow-count-to-profile.cjs │ │ │ ├── 20240428190413-create-sesssion-table.cjs │ │ │ ├── 20240330003433-create-post-like.cjs │ │ │ ├── 20240330003736-create-follow.cjs │ │ │ ├── 20240403235627-update-models-to-paranoid.cjs │ │ │ ├── 20240909170839-create-collection-post-link.cjs │ │ │ ├── 20240330002538-create-post-comment.cjs │ │ │ ├── 20240330001603-create-image.cjs │ │ │ ├── 20240329235510-create-post.cjs │ │ │ ├── 20240909170730-create-collection.cjs │ │ │ ├── 20230723020226-create-user.cjs │ │ │ └── 20240427170616-create-notification-table.cjs │ │ ├── models │ │ │ ├── Session.js │ │ │ ├── CollectionPostLink.js │ │ │ ├── Image.js │ │ │ ├── PostLike.js │ │ │ ├── index.js │ │ │ ├── PostComment.js │ │ │ ├── Collection.js │ │ │ ├── Follow.js │ │ │ ├── Post.js │ │ │ ├── Notification.js │ │ │ └── User.js │ │ └── config │ │ │ └── database.js │ ├── services │ │ ├── FollowService.js │ │ ├── EmailService.js │ │ └── NotificationService.js │ ├── config │ │ └── index.js │ ├── middleware │ │ ├── cache.js │ │ └── authorize.js │ ├── controllers │ │ ├── TestController.js │ │ ├── LikeController.js │ │ ├── CommentController.js │ │ ├── AdminController.js │ │ ├── UserController.js │ │ ├── FeedController.js │ │ └── ShareController.js │ ├── utils │ │ ├── index.js │ │ └── logger.js │ ├── templates │ │ └── shareMetadata.html │ └── scripts │ │ └── processImages.js ├── .env.example ├── .sequelizerc ├── run_local.sh ├── docker-compose.yml ├── error.log ├── reset_and_run_local.sh ├── config │ └── localdev │ │ ├── localhost.crt │ │ └── localhost.key └── package.json ├── .gitignore ├── entrypoint.sh ├── generateManifest.js ├── manifest.template.json ├── Dockerfile ├── .github └── workflows │ ├── docker-publish-dev.yml │ └── docker-publish-production.yml ├── biome.json ├── docker-compose.yml ├── generateMeta.js ├── nginx └── nginx.conf └── README.md /client/src/vite-env.d.js: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /assets/logo.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/assets/logo.psd -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@biomejs/biome": "^1.9.4" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/assets/logo_square.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /assets/thumbnail-title.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/assets/thumbnail-title.psd -------------------------------------------------------------------------------- /assets/logo_with_wordmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/assets/logo_with_wordmark.png -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /client/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/src/assets/fonts/Lobster.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/src/assets/fonts/Lobster.ttf -------------------------------------------------------------------------------- /client/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/src/assets/fonts/GreatVibes.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/src/assets/fonts/GreatVibes.otf -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /client/public/images/map/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/map/marker-icon.png -------------------------------------------------------------------------------- /client/public/images/splash/splash-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-1.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-10.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-10.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-11.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-11.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-12.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-12.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-13.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-13.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-14.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-14.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-2.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-3.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-4.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-5.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-6.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-7.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-7.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-8.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-8.webp -------------------------------------------------------------------------------- /client/public/images/splash/splash-9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/images/splash/splash-9.webp -------------------------------------------------------------------------------- /client/src/assets/fonts/LobsterTwo-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/src/assets/fonts/LobsterTwo-Bold.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/LobsterTwo-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/src/assets/fonts/LobsterTwo-Regular.ttf -------------------------------------------------------------------------------- /server/src/test.js: -------------------------------------------------------------------------------- 1 | async function main() { 2 | console.log('test') 3 | } 4 | 5 | try { 6 | main() 7 | } catch (error) { 8 | console.error(error) 9 | } 10 | -------------------------------------------------------------------------------- /client/public/screenshots/richer-install-mobile-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ShaneIsrael/Snapsmaps/HEAD/client/public/screenshots/richer-install-mobile-screenshot.png -------------------------------------------------------------------------------- /server/src/routes/test.routes.js: -------------------------------------------------------------------------------- 1 | import { test } from '../controllers/TestController' 2 | 3 | // module.exports = (app) => { 4 | // app.get('/api/test/image', test) 5 | // } 6 | -------------------------------------------------------------------------------- /server/src/constants/UserState.js: -------------------------------------------------------------------------------- 1 | const UserState = Object.freeze({ 2 | Active: 'active', 3 | Locked: 'locked', 4 | Banned: 'banned', 5 | }) 6 | 7 | export default UserState 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | server/node_modules/ 3 | server/db-data/ 4 | server/.env 5 | server/images 6 | client/node_modules/ 7 | db-data/ 8 | snapsmaps/content/ 9 | node_modules/ 10 | -------------------------------------------------------------------------------- /client/src/services/TestService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class TestService { 4 | test() { 5 | return Api().get('/test/image') 6 | } 7 | } 8 | 9 | const service = new TestService() 10 | 11 | export default service 12 | -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | VITE_MAX_POST_TITLE_LENGTH=500 2 | VITE_MAX_POST_COMMENT_LENGTH=750 3 | VITE_MAX_PROFILE_BIO_LENGTH=1000 4 | VITE_MAX_DISPLAY_NAME_LENGTH=32 5 | VITE_MAX_MENTION_LENGTH=20 6 | VITE_MAX_PASSWORD_LENGTH=64 7 | VITE_MAX_COLLECTION_TITLE_LENGTH=35 -------------------------------------------------------------------------------- /client/src/components/Map/SnapMap.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MapComponent from './MapComponent' 3 | 4 | function SnapMap({ ...rest }) { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | export default SnapMap 11 | -------------------------------------------------------------------------------- /client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /client/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/services/LikeService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class LikeService { 4 | hasLikedPost(id) { 5 | return Api().get('/likedPost', { params: { id } }) 6 | } 7 | likePost(id) { 8 | return Api().post('/likePost', { id }) 9 | } 10 | } 11 | 12 | const service = new LikeService() 13 | 14 | export default service 15 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | 3 | DEV_DB_USERNAME=postgres 4 | DEV_DB_PASSWORD=postgres 5 | DEV_DB_NAME=project 6 | DEV_DB_HOSTNAME=localhost 7 | 8 | SECRET_KEY=somesecretkey 9 | DOMAIN=http://localhost:3000 10 | 11 | S3_SECRET_KEY= 12 | S3_ACCESS_KEY= 13 | 14 | SMTP_HOST= 15 | SMTP_PORT=465 16 | SMTP_EMAIL= 17 | SMTP_PASSWORD= -------------------------------------------------------------------------------- /server/.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | config: path.resolve('src', 'database', 'config', 'database.js'), 5 | 'models-path': path.resolve('src', 'database', 'models'), 6 | 'seeders-path': path.resolve('src', 'database', 'seeders'), 7 | 'migrations-path': path.resolve('src', 'database', 'migrations'), 8 | } 9 | -------------------------------------------------------------------------------- /client/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import basicSsl from '@vitejs/plugin-basic-ssl' 2 | import react from '@vitejs/plugin-react' 3 | import svgr from 'vite-plugin-svgr' 4 | 5 | export default ({ mode }) => ({ 6 | plugins: [react(), mode === 'development' && basicSsl(), svgr()].filter(Boolean), 7 | build: { 8 | outDir: 'build', 9 | sourcemap: false, 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /server/run_local.sh: -------------------------------------------------------------------------------- 1 | mkdir db-data 2 | 3 | [ ! "$(docker ps -a | grep postgres-db)" ] && docker-compose up -d postgres && sleep 1 4 | 5 | until [ "`docker inspect -f {{.State.Running}} postgres-db`"=="true" ]; do 6 | echo "waiting for database to stand up..." 7 | sleep 5; 8 | done; 9 | 10 | sleep 10; 11 | 12 | npm i 13 | 14 | npm run migrate && npm run local -------------------------------------------------------------------------------- /client/src/services/CommentService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class CommentService { 4 | createPostComment(postId, body) { 5 | return Api().post('/comment', { postId, body }) 6 | } 7 | delete(id) { 8 | return Api().delete('/comment', { params: { id } }) 9 | } 10 | } 11 | 12 | const service = new CommentService() 13 | 14 | export default service 15 | -------------------------------------------------------------------------------- /server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:16.0-alpine 4 | container_name: postgres-db 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=snapsmaps 10 | ports: 11 | - '5432:5432' 12 | volumes: 13 | - ./db-data:/var/lib/postgresql/data 14 | -------------------------------------------------------------------------------- /client/src/assets/icons/PlusIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function PlusIcon(props) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default PlusIcon 12 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240514220131-add-userId-to-session.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('Sessions', 'userId', { 4 | type: Sequelize.INTEGER, 5 | }) 6 | }, 7 | 8 | down: async (queryInterface, Sequelize) => { 9 | await queryInterface.removeColumn('Sessions', 'userId') 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240614235109-add-push-token-to-user.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('users', 'pushToken', { 4 | type: Sequelize.STRING, 5 | }) 6 | }, 7 | 8 | down: async (queryInterface, Sequelize) => { 9 | await queryInterface.removeColumn('users', 'pushToken') 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240611183044-add-title-to-notification.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('notifications', 'title', { 4 | type: Sequelize.TEXT, 5 | }) 6 | }, 7 | 8 | down: async (queryInterface, Sequelize) => { 9 | await queryInterface.removeColumn('notifications', 'title') 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /client/src/assets/icons/ArrowLeft.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function ArrowLeft({ props }) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default ArrowLeft 12 | -------------------------------------------------------------------------------- /client/src/components/Search/ListEndCard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function ListEndCard({ label, onClick }) { 4 | return ( 5 |
6 |

{label}

7 |
8 | ) 9 | } 10 | 11 | export default ListEndCard 12 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240909220125-add-deletedAt-to-collections.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('collections', 'deletedAt', { allowNull: true, type: Sequelize.DATE }) 4 | }, 5 | 6 | down: async (queryInterface, Sequelize) => { 7 | await queryInterface.removeColumn('collections', 'deletedAt') 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /client/src/components/Feed/FeedWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const FeedWrapper = ({ size, children }, ref) => { 4 | return ( 5 |
10 | {children} 11 |
12 | ) 13 | } 14 | 15 | export default React.forwardRef(FeedWrapper) 16 | -------------------------------------------------------------------------------- /server/src/routes/root.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | 3 | class RootRoutes { 4 | router = Router() 5 | constructor() { 6 | this.initizeRoutes() 7 | } 8 | 9 | initizeRoutes() { 10 | this.router.get('/', (req, res, next) => 11 | res.status(200).json({ 12 | message: 'Welcome to the API', 13 | }), 14 | ) 15 | } 16 | } 17 | 18 | export default new RootRoutes().router 19 | -------------------------------------------------------------------------------- /client/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240512183056-public-private-posts.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('posts', 'public', { 4 | type: Sequelize.BOOLEAN, 5 | allowNull: false, 6 | defaultValue: true, 7 | }) 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn('posts', 'public') 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240513001411-add-nsfw-tag-to-posts.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('posts', 'nsfw', { 4 | type: Sequelize.BOOLEAN, 5 | allowNull: false, 6 | defaultValue: false, 7 | }) 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn('posts', 'nsfw') 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | nginx -g 'daemon on;' 4 | 5 | mkdir -p /content/images/post 6 | mkdir -p /content/images/profile 7 | mkdir -p /content/images/collection 8 | mkdir -p /content/images/thumb/120x120 9 | 10 | 11 | node /generateManifest.js 12 | node /generateMeta.js 13 | 14 | cd /app && npm run migrate 15 | cd /app && node --import=extensionless/register src/scripts/processImages.js 16 | node --import=extensionless/register /app/src/server.js -------------------------------------------------------------------------------- /client/src/hooks/useDebounce.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useDebounce = (value, delay) => { 4 | const [debouncedValue, setDebouncedValue] = useState(value) 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay) 8 | return () => { 9 | clearTimeout(timer) 10 | } 11 | }, [value, delay]) 12 | 13 | return debouncedValue 14 | } 15 | 16 | export default useDebounce 17 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240514193132-add-state-to-user.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('users', 'state', { 4 | type: Sequelize.ENUM, 5 | values: ['active', 'locked', 'banned'], 6 | defaultValue: 'active', 7 | }) 8 | }, 9 | 10 | down: async (queryInterface, Sequelize) => { 11 | await queryInterface.removeColumn('users', 'state') 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /server/src/routes/share.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/ShareController' 3 | 4 | class ShareRoutes { 5 | router = Router() 6 | constructor() { 7 | this.initizeRoutes() 8 | } 9 | 10 | initizeRoutes() { 11 | this.router.get('/share/post/:id', controller.post) 12 | this.router.get('/share/collection/:id', controller.collection) 13 | } 14 | } 15 | 16 | export default new ShareRoutes().router 17 | -------------------------------------------------------------------------------- /client/src/services/FeedService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class FeedService { 4 | getPublicFeed(lastDate) { 5 | return Api().get('/feed', { 6 | params: { 7 | lastDate, 8 | }, 9 | }) 10 | } 11 | getFollowingFeed(lastDate) { 12 | return Api().get('/feed/following', { 13 | params: { 14 | lastDate, 15 | }, 16 | }) 17 | } 18 | } 19 | 20 | const service = new FeedService() 21 | 22 | export default service 23 | -------------------------------------------------------------------------------- /server/error.log: -------------------------------------------------------------------------------- 1 | 2025-05-16T20:19:23.208Z [error]: 500 - logger.error is not a function - /api/collection?id=2 - GET - undefined - TypeError: logger.error is not a function 2 | at service.isFollowingUser (file:///Users/shane/git/snapsmaps/server/src/services/FollowService.js:15:12) 3 | at controller.getById (file:///Users/shane/git/snapsmaps/server/src/controllers/CollectionController.js:35:45) 4 | at process.processTicksAndRejections (node:internal/process/task_queues:105:5) 5 | -------------------------------------------------------------------------------- /client/src/assets/icons/SendIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function SendIcon(props) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default SendIcon 12 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240428042107-allow-empty-post-titles.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.changeColumn('posts', 'title', { 4 | type: Sequelize.TEXT, 5 | allowNull: true, 6 | }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.changeColumn('posts', 'title', { 11 | type: Sequelize.TEXT, 12 | allowNull: false, 13 | }) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /client/src/services/UserService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class UserService { 4 | search(query, page = 0) { 5 | return Api().get('/user/search', { 6 | params: { 7 | query, 8 | page, 9 | }, 10 | }) 11 | } 12 | notifications() { 13 | return Api().get('/user/notifications') 14 | } 15 | readNotifications() { 16 | return Api().put('/user/notifications') 17 | } 18 | } 19 | 20 | const service = new UserService() 21 | 22 | export default service 23 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240428182127-update-postComment-to-type-TEXT.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.changeColumn('postComments', 'body', { 4 | type: Sequelize.TEXT, 5 | allowNull: false, 6 | }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.changeColumn('postComments', 'body', { 11 | type: Sequelize.STRING, 12 | allowNull: false, 13 | }) 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /client/src/views/Landing.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Splash from '../components/Layout/Splash' 3 | import Login from '../components/Login/Login' 4 | import Signup from '../components/Signup/Signup' 5 | 6 | function Landing() { 7 | const [showLogin, setShowLogin] = useState(true) 8 | return ( 9 | 10 | {showLogin ? setShowLogin(false)} /> : setShowLogin(true)} />} 11 | 12 | ) 13 | } 14 | 15 | export default Landing 16 | -------------------------------------------------------------------------------- /client/src/assets/icons/FolderIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function FolderIcon(props) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default FolderIcon 12 | -------------------------------------------------------------------------------- /server/src/routes/like.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/LikeController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | 5 | class LikeRoutes { 6 | router = Router() 7 | 8 | constructor() { 9 | this.initializeRoutes() 10 | } 11 | 12 | initializeRoutes() { 13 | this.router.get('/likedPost', authorize, controller.likedPost) 14 | this.router.post('/likePost', authorize, controller.likePost) 15 | } 16 | } 17 | 18 | export default new LikeRoutes().router 19 | -------------------------------------------------------------------------------- /server/src/routes/comment.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/CommentController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | 5 | class CommentRoutes { 6 | router = Router() 7 | 8 | constructor() { 9 | this.initializeRoutes() 10 | } 11 | 12 | initializeRoutes() { 13 | this.router.post('/comment', authorize, controller.create) 14 | this.router.delete('/comment', authorize, controller.deleteComment) 15 | } 16 | } 17 | 18 | export default new CommentRoutes().router 19 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240615165532-move-push-token-to-session.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.removeColumn('users', 'pushToken') 4 | await queryInterface.addColumn('Sessions', 'fcmToken', { 5 | type: Sequelize.STRING, 6 | }) 7 | }, 8 | down: async (queryInterface, Sequelize) => { 9 | await queryInterface.addColumn('users', 'pushToken', { 10 | type: Sequelize.STRING, 11 | }) 12 | await queryInterface.removeColumn('Sessions', 'fcmToken') 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /client/src/assets/icons/CloseIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function CloseIcon(props) { 4 | return ( 5 | 6 | 11 | 12 | ) 13 | } 14 | 15 | export default CloseIcon 16 | -------------------------------------------------------------------------------- /client/src/services/index.js: -------------------------------------------------------------------------------- 1 | export { default as AuthService } from './AuthService' 2 | export { default as PostService } from './PostService' 3 | export { default as FeedService } from './FeedService' 4 | export { default as LikeService } from './LikeService' 5 | export { default as ProfileService } from './ProfileService' 6 | export { default as CommentService } from './CommentService' 7 | export { default as UserService } from './UserService' 8 | export { default as AdminService } from './AdminService' 9 | export { default as CollectionService } from './CollectionService' 10 | -------------------------------------------------------------------------------- /server/src/services/FollowService.js: -------------------------------------------------------------------------------- 1 | import Models from '../database/models' 2 | import logger from '../utils/logger' 3 | const { Follow } = Models 4 | const service = {} 5 | 6 | service.isFollowingUser = async (sessionUser, targetUserId) => { 7 | try { 8 | if (!sessionUser) return false 9 | 10 | const follow = await Follow.findOne({ 11 | where: { followingUserId: sessionUser.id, followedUserId: targetUserId }, 12 | }) 13 | return !!follow 14 | } catch (err) { 15 | logger.error(err) 16 | } 17 | return false 18 | } 19 | 20 | export default service 21 | -------------------------------------------------------------------------------- /client/src/components/Skeletons/AspectRatioPlaceholder.jsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@heroui/react' 2 | import React, { useState, useEffect } from 'react' 3 | 4 | const AspectRatioPlaceholder = ({ width, height, className, children }) => { 5 | return ( 6 |
12 | {children ? children : } 13 |
14 | ) 15 | } 16 | 17 | export default AspectRatioPlaceholder 18 | -------------------------------------------------------------------------------- /server/reset_and_run_local.sh: -------------------------------------------------------------------------------- 1 | docker-compose down && sleep 2 2 | 3 | # Clear the database data completely 4 | echo "resetting database data..." 5 | rm -r db-data && mkdir db-data && sleep 1 6 | 7 | [ ! "$(docker ps -a | grep postgres-db)" ] && docker-compose up -d postgres && sleep 1 8 | until [ "`docker inspect -f {{.State.Running}} postgres-db`"=="true" ]; do 9 | echo "waiting for database to stand up..." 10 | sleep 5; 11 | done; 12 | 13 | echo "database is up, wait 25 seconds for it to complete standing up before migration..." 14 | sleep 25; 15 | 16 | npm i 17 | npm run migrate && npm run local -------------------------------------------------------------------------------- /client/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import unusedImports from 'eslint-plugin-unused-imports' 2 | export default [ 3 | { 4 | plugins: { 5 | 'unused-imports': unusedImports, 6 | }, 7 | rules: { 8 | 'no-unused-vars': 'off', // or "@typescript-eslint/no-unused-vars": "off", 9 | 'unused-imports/no-unused-imports': 'error', 10 | 'unused-imports/no-unused-vars': [ 11 | 'warn', 12 | { 13 | vars: 'all', 14 | varsIgnorePattern: '^_', 15 | args: 'after-used', 16 | argsIgnorePattern: '^_', 17 | }, 18 | ], 19 | }, 20 | }, 21 | ] 22 | -------------------------------------------------------------------------------- /client/src/views/AdminDashboard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useAdmin } from '../hooks/useAdmin' 3 | import { useNavigate } from 'react-router-dom' 4 | 5 | function AdminDashboard() { 6 | const { loading, isAdmin } = useAdmin() 7 | const navigate = useNavigate() 8 | 9 | useEffect(() => { 10 | if (!loading && !isAdmin) { 11 | navigate('/feed') 12 | } 13 | }, [loading]) 14 | 15 | if (loading && !isAdmin) { 16 | return
loading...
17 | } 18 | 19 | return
Welcome Admin
20 | } 21 | 22 | export default AdminDashboard 23 | -------------------------------------------------------------------------------- /server/src/routes/feed.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/FeedController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | import cache from '../middleware/cache.js' 5 | 6 | class FeedRoutes { 7 | router = Router() 8 | 9 | constructor() { 10 | this.initializeRoutes() 11 | } 12 | 13 | initializeRoutes() { 14 | this.router.get('/feed', cache.publicCache(30), controller.public) 15 | this.router.get('/feed/following', authorize, cache.privateCache(30), controller.following) 16 | } 17 | } 18 | 19 | export default new FeedRoutes().router 20 | -------------------------------------------------------------------------------- /client/src/hooks/useAuth.js: -------------------------------------------------------------------------------- 1 | import AuthService from '../services/AuthService' 2 | import { useNavigate } from 'react-router-dom' 3 | 4 | const useAuth = () => { 5 | const navigate = useNavigate() 6 | const logout = async () => { 7 | try { 8 | await AuthService.logout() 9 | navigate('/') 10 | } catch (err) { 11 | throw err 12 | } 13 | } 14 | 15 | const login = async (email, password) => { 16 | try { 17 | await AuthService.login(email, password) 18 | } catch (err) { 19 | throw err 20 | } 21 | } 22 | 23 | return { login, logout } 24 | } 25 | 26 | export { useAuth } 27 | -------------------------------------------------------------------------------- /client/src/hooks/useAdmin.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import { AdminService } from '../services' 3 | 4 | const useAdmin = () => { 5 | const [isAdmin, setIsAdmin] = useState(null) 6 | const [loading, setLoading] = useState(true) 7 | 8 | useEffect(() => { 9 | const checkAdminStatus = async () => { 10 | try { 11 | await AdminService.isAdmin() 12 | setIsAdmin(true) 13 | } catch (err) { 14 | setIsAdmin(false) 15 | } 16 | setLoading(false) 17 | } 18 | checkAdminStatus() 19 | }, []) 20 | 21 | return { loading, isAdmin } 22 | } 23 | 24 | export { useAdmin } 25 | -------------------------------------------------------------------------------- /server/src/routes/post.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/PostController' 3 | import { authorize } from '../middleware/authorize' 4 | 5 | class PostRoutes { 6 | router = Router() 7 | 8 | constructor() { 9 | this.initializeRoutes() 10 | } 11 | 12 | initializeRoutes() { 13 | this.router.get('/post', controller.getById) 14 | this.router.get('/post/likes', controller.getPostLikes) 15 | this.router.post('/post', authorize, controller.create) 16 | this.router.delete('/post', authorize, controller.deletePost) 17 | } 18 | } 19 | 20 | export default new PostRoutes().router 21 | -------------------------------------------------------------------------------- /client/src/assets/icons/MapPinIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export const MapPinIcon = (props) => { 4 | return ( 5 | 6 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /client/src/services/AuthService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class AuthService { 4 | login(email, password) { 5 | return Api().post('/auth/login', { email, password }) 6 | } 7 | register(email, displayName, mention, password) { 8 | return Api().post('/auth/register', { email, displayName, mention, password, displayName }) 9 | } 10 | logout() { 11 | return Api().post('/auth/logout') 12 | } 13 | hasSession() { 14 | return Api().get('/auth/session') 15 | } 16 | verify(email, token) { 17 | return Api().get(`/auth/verify/${email}/${token}`) 18 | } 19 | } 20 | 21 | const service = new AuthService() 22 | 23 | export default service 24 | -------------------------------------------------------------------------------- /client/src/services/AdminService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class AdminService { 4 | isAdmin() { 5 | return Api().get('/admin/check') 6 | } 7 | deletePost(id) { 8 | return Api().delete('/admin/post', { 9 | params: { id }, 10 | }) 11 | } 12 | deleteComment(id) { 13 | return Api().delete('/admin/comment', { 14 | params: { id }, 15 | }) 16 | } 17 | markPostNSFW(id) { 18 | return Api().put('/admin/post/nsfw', { 19 | id, 20 | }) 21 | } 22 | banUser(mention) { 23 | return Api().put('/admin/user/ban', { 24 | mention, 25 | }) 26 | } 27 | } 28 | 29 | const service = new AdminService() 30 | 31 | export default service 32 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240412165948-add-follow-count-to-profile.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('users', 'followingCount', { 4 | allowNull: false, 5 | type: Sequelize.INTEGER, 6 | defaultValue: 0, 7 | }) 8 | await queryInterface.addColumn('users', 'followersCount', { 9 | allowNull: false, 10 | type: Sequelize.INTEGER, 11 | defaultValue: 0, 12 | }) 13 | }, 14 | 15 | down: async (queryInterface, Sequelize) => { 16 | await queryInterface.removeColumn('users', 'followingCount') 17 | await queryInterface.removeColumn('users', 'followersCount') 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240428190413-create-sesssion-table.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('Sessions', { 4 | sid: { 5 | type: Sequelize.STRING(36), 6 | primaryKey: true, 7 | }, 8 | expires: Sequelize.DATE, 9 | data: Sequelize.TEXT, 10 | createdAt: { 11 | allowNull: false, 12 | type: Sequelize.DATE, 13 | }, 14 | updatedAt: { 15 | allowNull: false, 16 | type: Sequelize.DATE, 17 | }, 18 | }) 19 | }, 20 | down: async (queryInterface, Sequelize) => { 21 | await queryInterface.dropTable('Sessions') 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | import { HeroUIProvider } from '@heroui/react' 2 | import { createRoot } from 'react-dom/client' 3 | import { Toaster } from 'sonner' 4 | import App from './App' 5 | import './index.css' 6 | import 'react-lazy-load-image-component/src/effects/blur.css' 7 | import 'react-lazy-load-image-component/src/effects/opacity.css' 8 | import 'leaflet/dist/leaflet.css' 9 | 10 | const rootElement = document.getElementById('root') 11 | const root = createRoot(rootElement) 12 | 13 | root.render( 14 | 15 |
16 | 17 | 18 |
19 |
, 20 | ) 21 | -------------------------------------------------------------------------------- /server/src/database/models/Session.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize' 2 | export default (sequelize, DataTypes) => { 3 | class Session extends Model { 4 | static associate(models) { 5 | // define association here 6 | const { User } = models 7 | Session.belongsTo(User) 8 | } 9 | } 10 | Session.init( 11 | { 12 | sid: { 13 | type: DataTypes.STRING(36), 14 | primaryKey: true, 15 | }, 16 | expires: DataTypes.DATE, 17 | data: DataTypes.TEXT, 18 | userId: DataTypes.INTEGER, 19 | fcmToken: DataTypes.STRING, 20 | }, 21 | { 22 | modelName: 'Sessions', 23 | sequelize, 24 | }, 25 | ) 26 | return Session 27 | } 28 | -------------------------------------------------------------------------------- /server/src/routes/collection.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/CollectionController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | 5 | class CollectionRoutes { 6 | router = Router() 7 | 8 | constructor() { 9 | this.initializeRoutes() 10 | } 11 | 12 | initializeRoutes() { 13 | this.router.get('/collection', controller.getById) 14 | this.router.post('/collection', authorize, controller.create) 15 | this.router.delete('/collection', authorize, controller.deleteCollection) 16 | this.router.delete('/collection/item', authorize, controller.removeCollectionItem) 17 | } 18 | } 19 | 20 | export default new CollectionRoutes().router 21 | -------------------------------------------------------------------------------- /server/src/config/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | environment: process.env.NODE_ENV || 'development', 3 | app: { 4 | maxPostTitleLength: 500, 5 | maxPostCommentLength: 750, 6 | maxProfileBioLength: 1000, 7 | maxDisplayNameLength: 32, 8 | maxMentionLength: 20, 9 | maxPasswordLength: 64, 10 | maxCollectionTitleLength: 35, 11 | }, 12 | admins: (process.env.ADMINS || '').split(','), 13 | contentRoot: process.env.NODE_ENV !== 'development' ? '/content' : process.cwd(), 14 | smtpHost: process.env.SMTP_HOST, 15 | smtpPort: process.env.SMTP_PORT, 16 | smtpEmail: process.env.SMTP_EMAIL, 17 | smtpPassword: process.env.SMTP_PASSWORD, 18 | isProduction: process.env.NODE_ENV !== 'development', 19 | } 20 | -------------------------------------------------------------------------------- /server/src/routes/auth.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/AuthController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | 5 | class AuthRoutes { 6 | router = Router() 7 | 8 | constructor() { 9 | this.initializeRoutes() 10 | } 11 | 12 | initializeRoutes() { 13 | this.router.post('/auth/register', controller.register) 14 | this.router.post('/auth/login', controller.login) 15 | this.router.post('/auth/logout', authorize, controller.logout) 16 | this.router.get('/auth/session', controller.hasSession) 17 | this.router.get('/auth/verify/:email/:token', controller.verifyEmail) 18 | } 19 | } 20 | 21 | export default new AuthRoutes().router 22 | -------------------------------------------------------------------------------- /client/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | import { heroui } from '@heroui/react' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}', './node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | vibes: 'GreatVibes', 10 | lobster: 'Lobster', 11 | lobsterTwo: 'LobsterTwo', 12 | lobsterTwoBold: 'LobsterTwoBold', 13 | }, 14 | }, 15 | }, 16 | darkMode: 'class', 17 | plugins: [ 18 | heroui({ 19 | themes: { 20 | dark: { 21 | colors: { 22 | background: '#020617', 23 | }, 24 | }, 25 | }, 26 | }), 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /server/src/middleware/cache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The cached content can be shared amongst all clients. 3 | * @param {number} timeInSeconds How long to cachethe response for. 4 | * @returns 5 | */ 6 | const publicCache = (timeInSeconds) => (req, res, next) => { 7 | res.header('Cache-Control', `public, max-age=${timeInSeconds}`) 8 | next() 9 | } 10 | 11 | /** 12 | * The cached content has client specific or private data 13 | * @param {number} timeInSeconds How long to cachethe response for. 14 | * @returns 15 | */ 16 | const privateCache = (timeInSeconds) => (req, res, next) => { 17 | res.header('Cache-Control', `private, max-age=${timeInSeconds}`) 18 | next() 19 | } 20 | 21 | export default { 22 | publicCache, 23 | privateCache, 24 | } 25 | -------------------------------------------------------------------------------- /client/src/assets/icons/Sfw.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Sfw(props) { 4 | return ( 5 | 6 | 7 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default Sfw 19 | -------------------------------------------------------------------------------- /client/src/hooks/useAuthed.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import AuthService from '../services/AuthService' 3 | 4 | const useAuthed = () => { 5 | const [isAuthenticated, setIsAuthenticated] = useState(null) 6 | const [user, setUser] = useState(null) 7 | const [loading, setLoading] = useState(true) 8 | 9 | useEffect(() => { 10 | const checkAuthStatus = async () => { 11 | try { 12 | const session = (await AuthService.hasSession()).data 13 | setIsAuthenticated(!!session) 14 | setUser(session ? session : null) 15 | } catch (err) { 16 | console.error(err) 17 | } 18 | setLoading(false) 19 | } 20 | checkAuthStatus() 21 | }, []) 22 | 23 | return { loading, user, isAuthenticated } 24 | } 25 | 26 | export { useAuthed } 27 | -------------------------------------------------------------------------------- /server/src/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/UserController' 3 | import { authorize, requireSession } from '../middleware/authorize' 4 | import cache from '../middleware/cache' 5 | 6 | class UserRoutes { 7 | router = Router() 8 | 9 | constructor() { 10 | this.initializeRoutes() 11 | } 12 | 13 | initializeRoutes() { 14 | this.router.get('/user/search', authorize, cache.publicCache(30), controller.search) 15 | this.router.get('/user/notifications', requireSession, controller.getNotifications) 16 | this.router.put('/user/notifications', requireSession, controller.readNotifications) 17 | this.router.put('/user/push/token', requireSession, controller.updatePushToken) 18 | } 19 | } 20 | 21 | export default new UserRoutes().router 22 | -------------------------------------------------------------------------------- /client/src/components/Feed/Feed.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Post from '../Post/Post' 3 | import { Spinner } from "@heroui/react" 4 | 5 | function Feed({ loading, posts, onOpenPostImage, user, isAuthenticated, ...rest }) { 6 | if (loading) 7 | return ( 8 |
9 | 10 |
11 | ) 12 | return posts?.map((post) => ( 13 | 0} 17 | post={post} 18 | onOpenModal={onOpenPostImage} 19 | user={user} 20 | isAuthenticated={isAuthenticated} 21 | {...rest} 22 | /> 23 | )) 24 | } 25 | 26 | Feed.refresh = () => {} 27 | 28 | export default Feed 29 | -------------------------------------------------------------------------------- /client/src/assets/icons/CameraIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function CameraIcon(props) { 4 | return ( 5 | 6 | 7 | 12 | 13 | ) 14 | } 15 | 16 | export default CameraIcon 17 | -------------------------------------------------------------------------------- /client/src/services/Api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { getUrl } from '../common/utils' 3 | 4 | const URL = getUrl() 5 | 6 | const cancelToken = axios.CancelToken.source() 7 | 8 | const instance = axios.create({ 9 | baseURL: `${URL}/api`, 10 | }) 11 | 12 | instance.interceptors.request.use(async (config) => { 13 | config.cancelToken = cancelToken.token 14 | return config 15 | }) 16 | 17 | instance.interceptors.response.use( 18 | (response) => { 19 | return response 20 | }, 21 | (error) => { 22 | if (!axios.isCancel(error)) { 23 | if (error.response?.status === 403) { 24 | window.location.href = '/' 25 | } 26 | return Promise.reject(error) 27 | } 28 | return null 29 | }, 30 | ) 31 | const Api = () => { 32 | instance.defaults.withCredentials = true 33 | return instance 34 | } 35 | 36 | export default Api 37 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240330003433-create-post-like.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('postLikes', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | userId: { 11 | type: Sequelize.INTEGER, 12 | allowNull: false, 13 | }, 14 | postId: { 15 | type: Sequelize.INTEGER, 16 | allowNull: false, 17 | }, 18 | createdAt: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | updatedAt: { 23 | allowNull: false, 24 | type: Sequelize.DATE, 25 | }, 26 | }) 27 | }, 28 | down: async (queryInterface, Sequelize) => { 29 | await queryInterface.dropTable('postLikes') 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240330003736-create-follow.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('follows', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | followingUserId: { 11 | type: Sequelize.INTEGER, 12 | allowNull: false, 13 | }, 14 | followedUserId: { 15 | type: Sequelize.INTEGER, 16 | allowNull: false, 17 | }, 18 | createdAt: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | updatedAt: { 23 | allowNull: false, 24 | type: Sequelize.DATE, 25 | }, 26 | }) 27 | }, 28 | down: async (queryInterface, Sequelize) => { 29 | await queryInterface.dropTable('follows') 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240403235627-update-models-to-paranoid.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.addColumn('users', 'deletedAt', { allowNull: true, type: Sequelize.DATE }) 4 | await queryInterface.addColumn('posts', 'deletedAt', { allowNull: true, type: Sequelize.DATE }) 5 | await queryInterface.addColumn('images', 'deletedAt', { allowNull: true, type: Sequelize.DATE }) 6 | await queryInterface.addColumn('postComments', 'deletedAt', { allowNull: true, type: Sequelize.DATE }) 7 | }, 8 | 9 | down: async (queryInterface, Sequelize) => { 10 | await queryInterface.removeColumn('users', 'deletedAt') 11 | await queryInterface.removeColumn('posts', 'deletedAt') 12 | await queryInterface.removeColumn('images', 'deletedAt') 13 | await queryInterface.removeColumn('postComments', 'deletedAt') 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240909170839-create-collection-post-link.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('collectionPostLinks', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | collectionId: { 11 | allowNull: false, 12 | type: Sequelize.INTEGER, 13 | }, 14 | postId: { 15 | allowNulL: false, 16 | type: Sequelize.INTEGER, 17 | }, 18 | createdAt: { 19 | allowNull: false, 20 | type: Sequelize.DATE, 21 | }, 22 | updatedAt: { 23 | allowNull: false, 24 | type: Sequelize.DATE, 25 | }, 26 | }) 27 | }, 28 | down: async (queryInterface, Sequelize) => { 29 | await queryInterface.dropTable('collectionPostLinks') 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /client/src/assets/icons/ChatIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function ChatIcon(props) { 4 | return ( 5 | 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default ChatIcon 13 | -------------------------------------------------------------------------------- /server/src/routes/admin.routes.js: -------------------------------------------------------------------------------- 1 | import controller from '../controllers/AdminController' 2 | import { authorize, verifyAdmin } from '../middleware/authorize' 3 | 4 | import { Router } from 'express' 5 | 6 | class AdminRoutes { 7 | router = Router() 8 | constructor() { 9 | this.initizeRoutes() 10 | } 11 | 12 | initizeRoutes() { 13 | this.router.get('/admin/check', authorize, verifyAdmin, (req, res) => res.sendStatus(200)) 14 | this.router.delete('/admin/post', authorize, verifyAdmin, controller.deletePost) 15 | this.router.delete('/admin/comment', authorize, verifyAdmin, controller.deleteComment) 16 | this.router.delete('/admin/image/destroy', authorize, verifyAdmin, controller.hardDestroyImage) 17 | this.router.put('/admin/user/ban', authorize, verifyAdmin, controller.banUser) 18 | this.router.put('/admin/post/nsfw', authorize, verifyAdmin, controller.markPostNsfw) 19 | } 20 | } 21 | 22 | export default new AdminRoutes().router 23 | -------------------------------------------------------------------------------- /client/src/components/Search/UserCard.jsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@heroui/react" 2 | import React from 'react' 3 | import { getAssetUrl } from '../../common/utils' 4 | import { useNavigate } from 'react-router-dom' 5 | 6 | function UserCard({ user }) { 7 | const navigate = useNavigate() 8 | const handleClick = () => { 9 | navigate(`/user/${user.mention}`) 10 | } 11 | 12 | return ( 13 |
14 | 15 |
16 |

{user.displayName}

17 |
@{user.mention}
18 |
19 |
20 | ) 21 | } 22 | 23 | export default UserCard 24 | -------------------------------------------------------------------------------- /client/src/components/Follows/UserCard.jsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from "@heroui/react" 2 | import React from 'react' 3 | import { getAssetUrl } from '../../common/utils' 4 | import { useNavigate } from 'react-router-dom' 5 | 6 | function UserCard({ user }) { 7 | const navigate = useNavigate() 8 | const handleClick = () => { 9 | navigate(`/user/${user.mention}`) 10 | } 11 | 12 | if (!user) return false 13 | return ( 14 |
15 | 16 |
17 |

{user.displayName}

18 |
@{user.mention}
19 |
20 |
21 | ) 22 | } 23 | 24 | export default UserCard 25 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240330002538-create-post-comment.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('postComments', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | userId: { 11 | allowNull: false, 12 | type: Sequelize.INTEGER, 13 | }, 14 | postId: { 15 | allowNull: false, 16 | type: Sequelize.INTEGER, 17 | }, 18 | body: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | }, 22 | createdAt: { 23 | allowNull: false, 24 | type: Sequelize.DATE, 25 | }, 26 | updatedAt: { 27 | allowNull: false, 28 | type: Sequelize.DATE, 29 | }, 30 | }) 31 | }, 32 | down: async (queryInterface, Sequelize) => { 33 | await queryInterface.dropTable('postComments') 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /client/src/services/CollectionService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class CollectionService { 4 | create(title, publicCollection, image, items, onUploadProgress, signal) { 5 | return Api().postForm( 6 | '/collection', 7 | { title, public: publicCollection, image, items }, 8 | { 9 | timeout: 300 * 1000, 10 | signal, 11 | onUploadProgress: (event) => { 12 | const { loaded, total } = event 13 | let percent = Math.floor((loaded * 100) / total) 14 | onUploadProgress(percent, loaded / 1024 / 1024, total / 1024 / 1024) 15 | }, 16 | }, 17 | ) 18 | } 19 | get(id) { 20 | return Api().get('/collection', { params: { id } }) 21 | } 22 | delete(id) { 23 | return Api().delete('/collection', { params: { id } }) 24 | } 25 | removeItem(id) { 26 | return Api().delete('/collection/item', { params: { id } }) 27 | } 28 | } 29 | 30 | const service = new CollectionService() 31 | 32 | export default service 33 | -------------------------------------------------------------------------------- /server/src/routes/index.js: -------------------------------------------------------------------------------- 1 | import adminRoutes from './admin.routes' 2 | import authRoutes from './auth.routes' 3 | import collectionRoutes from './collection.routes' 4 | import commentRoutes from './comment.routes' 5 | import feedRoutes from './feed.routes' 6 | import likeRoutes from './like.routes' 7 | import postRoutes from './post.routes' 8 | import profileRoutes from './profile.routes' 9 | import rootApiRoutes from './root.routes' 10 | import shareRoutes from './share.routes' 11 | import userRoutes from './user.routes' 12 | 13 | export default class Routes { 14 | constructor(app) { 15 | app.use('/', shareRoutes) 16 | app.use('/api', rootApiRoutes) 17 | app.use('/api', adminRoutes) 18 | app.use('/api', authRoutes) 19 | app.use('/api', collectionRoutes) 20 | app.use('/api', commentRoutes) 21 | app.use('/api', feedRoutes) 22 | app.use('/api', likeRoutes) 23 | app.use('/api', postRoutes) 24 | app.use('/api', profileRoutes) 25 | app.use('/api', userRoutes) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/assets/icons/PhotoIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export const PhotoIcon = (props) => ( 3 | 4 | 5 | 6 | 7 | 8 | ) 9 | -------------------------------------------------------------------------------- /client/src/services/PostService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class PostService { 4 | create(title, publicPost, nsfw = false, gps, image, locationEnabled, onUploadProgress, signal) { 5 | return Api().postForm( 6 | '/post', 7 | { title, public: publicPost, nsfw, ...gps, image, locationEnabled }, 8 | { 9 | timeout: 300 * 1000, 10 | signal, 11 | onUploadProgress: (event) => { 12 | const { loaded, total } = event 13 | let percent = Math.floor((loaded * 100) / total) 14 | onUploadProgress(percent, loaded / 1024 / 1024, total / 1024 / 1024) 15 | }, 16 | }, 17 | ) 18 | } 19 | get(id) { 20 | return Api().get('/post', { params: { id } }) 21 | } 22 | delete(id) { 23 | return Api().delete('/post', { params: { id } }) 24 | } 25 | getPostLikes(id, lastDate, pageSize = 25) { 26 | return Api().get('/post/likes', { params: { id, lastDate, pageSize } }) 27 | } 28 | } 29 | 30 | const service = new PostService() 31 | 32 | export default service 33 | -------------------------------------------------------------------------------- /client/src/components/Cropper/Cropper.jsx: -------------------------------------------------------------------------------- 1 | import EasyCropper from 'react-easy-crop' 2 | import { useImageCropContext } from '../../providers/ImageCropProvider' 3 | 4 | const Cropper = () => { 5 | const { image, zoom, max_zoom, setZoom, rotation, setRotation, crop, setCrop, onCropComplete } = useImageCropContext() 6 | 7 | return ( 8 | 33 | ) 34 | } 35 | 36 | export default Cropper 37 | -------------------------------------------------------------------------------- /client/src/views/NotFound.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Logo from '../assets/logo/dark/Logo' 3 | 4 | function NotFound({ object }) { 5 | function getMessage() { 6 | if (object === 'post') return 'It would seem that post no longer exists.' 7 | if (object === 'collection') return 'That collection no longer exists.' 8 | return 'That resource does not exist.' 9 | } 10 | return ( 11 |
12 |
13 |
14 | 15 |
Sorry
16 |
{getMessage()}
17 |
18 |
© 2024 Snapsmaps by Shane Israel
19 |
20 |
21 | ) 22 | } 23 | 24 | export default NotFound 25 | -------------------------------------------------------------------------------- /client/src/components/Layout/AcmeLogo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export const AcmeLogo = () => ( 3 | 4 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /generateManifest.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs') 2 | const path = require('node:path') 3 | 4 | const manifestTemplatePath = path.join(__dirname, '/manifest.template.json') 5 | const manifestOutputPath = path.join(__dirname, '/app/build/manifest.json') 6 | 7 | const template = fs.readFileSync(manifestTemplatePath, 'utf8') 8 | 9 | const manifest = template 10 | .replace('${SITE_SHORT_NAME}', process.env.SHORT_NAME || 'Snapsmaps') 11 | .replace('${SITE_NAME}', process.env.NAME || 'Snapsmaps') 12 | .replace('${START_URL}', process.env.START_URL || 'https://mydomain.tld') 13 | .replace('${THEME_COLOR}', process.env.THEME_COLOR || '#020617') 14 | .replace('${BACKGROUND_COLOR}', process.env.BACKGROUND_COLOR || '#020617') 15 | .replace('${SCOPE}', process.env.SCOPE || 'https://mydomain.tld') 16 | .replace('${DESCRIPTION}', process.env.DESCRIPTION || 'Easily share photos with a map pin to friends and family.') 17 | .replace('${MANIFEST_ID}', process.env.ID || 'asdfasdfasdf') 18 | 19 | fs.writeFileSync(manifestOutputPath, manifest, 'utf8') 20 | console.log('Manifest generated successfully!') 21 | -------------------------------------------------------------------------------- /server/src/database/models/CollectionPostLink.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize' 2 | export default (sequelize, DataTypes) => { 3 | class CollectionPostLink extends Model { 4 | /** 5 | * Helper method for defining associations. 6 | * This method is not a part of Sequelize lifecycle. 7 | * The `models/index` file will call this method automatically. 8 | */ 9 | static associate(models) { 10 | // define association here 11 | const { Collection, Post } = models 12 | CollectionPostLink.belongsTo(Collection) 13 | CollectionPostLink.belongsTo(Post) 14 | } 15 | } 16 | CollectionPostLink.init( 17 | { 18 | collectionId: { 19 | type: DataTypes.INTEGER, 20 | allowNull: false, 21 | references: { model: sequelize.models.collection, key: 'id' }, 22 | }, 23 | postId: { 24 | type: DataTypes.INTEGER, 25 | allowNull: false, 26 | references: { model: sequelize.models.post, key: 'id' }, 27 | }, 28 | }, 29 | { 30 | modelName: 'collectionPostLink', 31 | sequelize, 32 | }, 33 | ) 34 | return CollectionPostLink 35 | } 36 | -------------------------------------------------------------------------------- /client/src/hooks/useNotifications.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | import UserService from '../services/UserService' 3 | 4 | const useNotifications = () => { 5 | const [notifications, setNotifications] = useState([]) 6 | const [unreadCount, setUnreadCount] = useState(0) 7 | const [loading, setLoading] = useState(true) 8 | 9 | const refresh = async () => { 10 | setLoading(true) 11 | try { 12 | const resp = await UserService.notifications() 13 | setNotifications(resp.data) 14 | if (resp.data) { 15 | setUnreadCount(resp.data.filter((notification) => !notification.read).length || 0) 16 | } 17 | } catch (err) { 18 | console.error(err) 19 | } 20 | setLoading(false) 21 | } 22 | const read = async () => { 23 | try { 24 | if (unreadCount > 0) { 25 | await UserService.readNotifications() 26 | refresh() 27 | } 28 | } catch (err) { 29 | console.error(err) 30 | } 31 | } 32 | useEffect(() => { 33 | refresh() 34 | }, []) 35 | 36 | return { loading, notifications, unreadCount, refresh, read } 37 | } 38 | 39 | export { useNotifications } 40 | -------------------------------------------------------------------------------- /server/src/database/config/database.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import logger from '../../utils/logger.js' 3 | dotenv.config() 4 | 5 | export default { 6 | development: { 7 | username: process.env.DB_USERNAME, 8 | password: process.env.DB_PASSWORD, 9 | database: process.env.DB_NAME, 10 | host: process.env.DB_HOST || 5432, 11 | port: process.env.DB_PORT, 12 | dialect: 'postgres', 13 | options: { 14 | timezone: 'utc', 15 | logging: (msg) => logger.debug(msg), 16 | // logging: false, 17 | pool: { 18 | max: 5, 19 | min: 0, 20 | acquire: 30000, 21 | idle: 10000, 22 | }, 23 | }, 24 | }, 25 | production: { 26 | username: process.env.DB_USERNAME, 27 | password: process.env.DB_PASSWORD, 28 | database: process.env.DB_NAME, 29 | host: process.env.DB_HOST, 30 | port: process.env.DB_PORT || 5432, 31 | dialect: 'postgres', 32 | options: { 33 | timezone: 'utc', 34 | logging: false, 35 | pool: { 36 | max: 10, 37 | min: 0, 38 | acquire: 30000, 39 | idle: 10000, 40 | }, 41 | }, 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240330001603-create-image.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('images', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | userId: { 11 | allowNull: false, 12 | type: Sequelize.INTEGER, 13 | }, 14 | reference: { 15 | type: Sequelize.STRING, 16 | allowNull: false, 17 | }, 18 | width: { 19 | type: Sequelize.INTEGER, 20 | }, 21 | height: { 22 | type: Sequelize.INTEGER, 23 | }, 24 | latitude: { 25 | type: Sequelize.FLOAT, 26 | }, 27 | longitude: { 28 | type: Sequelize.FLOAT, 29 | }, 30 | createdAt: { 31 | allowNull: false, 32 | type: Sequelize.DATE, 33 | }, 34 | updatedAt: { 35 | allowNull: false, 36 | type: Sequelize.DATE, 37 | }, 38 | }) 39 | }, 40 | down: async (queryInterface, Sequelize) => { 41 | await queryInterface.dropTable('images') 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /manifest.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "${SITE_SHORT_NAME}", 3 | "name": "${SITE_NAME}", 4 | "start_url": "${START_URL}", 5 | "theme_color": "${THEME_COLOR}", 6 | "background_color": "${BACKGROUND_COLOR}", 7 | "scope": "${SCOPE}", 8 | "description": "${DESCRIPTION}", 9 | "id": "${MANIFEST_ID}", 10 | "icons": [ 11 | { 12 | "src": "favicon.ico", 13 | "sizes": "64x64 32x32 24x24 16x16", 14 | "type": "image/x-icon" 15 | }, 16 | { 17 | "src": "/android-chrome-192x192.png", 18 | "sizes": "192x192", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/android-chrome-512x512.png", 23 | "sizes": "512x512", 24 | "type": "image/png" 25 | } 26 | ], 27 | "screenshots": [ 28 | { 29 | "src": "/screenshots/richer-install-mobile-screenshot.png", 30 | "sizes": "1440x2690", 31 | "type": "image/png", 32 | "form_factor": "narrow" 33 | } 34 | ], 35 | "launch_handler": { 36 | "client_mode": "focus-existing" 37 | }, 38 | "display": "fullscreen", 39 | "orientation": "portrait-primary", 40 | "lang": "en", 41 | "categories": ["photo", "social", "travel"] 42 | } 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # build environment 2 | FROM node:20.12-alpine3.18 as build 3 | WORKDIR . 4 | 5 | ENV PATH node_modules/.bin:$PATH 6 | COPY client/package.json ./ 7 | COPY client/package-lock.json ./ 8 | COPY client/public ./ 9 | RUN npm i -g --silent npm@9.1.3 10 | RUN npm i --silent 11 | COPY client/ ./ 12 | RUN npm run build 13 | 14 | # production environment 15 | FROM node:20.12-alpine3.18 16 | RUN apk add --update nginx bash 17 | RUN mkdir /var/cache/nginx 18 | COPY --from=build /build /app/build 19 | COPY nginx/nginx.conf /etc/nginx/nginx.conf 20 | COPY entrypoint.sh / 21 | COPY generateMeta.js / 22 | COPY generateManifest.js / 23 | COPY manifest.template.json / 24 | 25 | # Copy Assets 26 | COPY assets/logo_with_wordmark.png /content/images/assets 27 | COPY assets/logo_with_wordmark.svg /content/images/assets 28 | 29 | # Create app directory 30 | WORKDIR /app 31 | 32 | # Install app dependencies 33 | COPY server/package*.json ./ 34 | RUN npm ci --omit=dev 35 | RUN npm install -g sequelize-cli 36 | 37 | # Move source 38 | COPY server/src ./src 39 | COPY server/.sequelizerc . 40 | 41 | 42 | EXPOSE 80 43 | ENV NODE_ENV=production 44 | 45 | WORKDIR / 46 | CMD ["bash", "/entrypoint.sh"] 47 | -------------------------------------------------------------------------------- /server/src/controllers/TestController.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { ExifImage } from 'exif' 3 | import { convertDMSToDD } from '../utils' 4 | 5 | const controller = {} 6 | 7 | controller.test = async (req, res, next) => { 8 | const imagePath = path.resolve('../server/images/test_image_1.jpg') 9 | 10 | try { 11 | const gpsData = await new Promise( 12 | (resolve, reject) => 13 | new ExifImage({ image: imagePath }, (err, exifData) => { 14 | if (err) return reject(err) 15 | 16 | const latitude = exifData.gps.GPSLatitude 17 | const longitude = exifData.gps.GPSLongitude 18 | const latRef = exifData.gps.GPSLatitudeRef 19 | const lngRef = exifData.gps.GPSLongitudeRef 20 | 21 | resolve({ 22 | lat: convertDMSToDD(latitude[0], latitude[1], latitude[2], latRef), 23 | lng: convertDMSToDD(longitude[0], longitude[1], longitude[2], lngRef), 24 | }) 25 | }), 26 | ) 27 | 28 | res.status(200).send({ 29 | image: 'https://i.imgur.com/OS0EWYn.jpeg', 30 | gps: gpsData, 31 | }) 32 | } catch (err) { 33 | next(err) 34 | } 35 | } 36 | 37 | export default controller 38 | -------------------------------------------------------------------------------- /server/config/localdev/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDzCCAfegAwIBAgIUa6DkZys9weBeWD9y+RPmuxUHOxgwDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDUxMjE5MzE1NFoXDTI1MDYx 4 | MTE5MzE1NFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAv1Ccz+UQs2FBLXH9DzI5gtQuus+xvAOSy47jb+DxhAM2 6 | QCLwt+redE8cFrdvsx/VY4onJcoBiSbnZ0z8if8NLn4qWXhIiZn8UPUeSJ/AW6sa 7 | 68TKZqQ1ZwuXH6LPoFfTUjempA+W1qfraBj/vVaRiW8i2IVdnjii8PMSfC/I/CPP 8 | FKIdJZdR1nY+UgWOQeYVMjCuVf1+25d4IFjAhrv4B9mThLiJvab+x9h41OM8M4GZ 9 | akzC72AzjH86M0GfhirfTEjCZZ2ydwJRq1wrL54bWzXjVdp7oQUwF4ozAjq91AZs 10 | +Zy5f/4UC/uGmQ2w4DEaKQ7RDTycZufV/QxhjgxXtwIDAQABo1kwVzAUBgNVHREE 11 | DTALgglsb2NhbGhvc3QwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB 12 | MB0GA1UdDgQWBBQATTDt2G4Ky7rybpdx8hkMk7HPajANBgkqhkiG9w0BAQsFAAOC 13 | AQEAuu+sqreUig3zA1VgKjIgipeoaSjW3VJ9yt5M/N+w0J17Q6NQ8BMfG7fzV+uU 14 | 8+XjbQDmv3/9sEenSYw3bR6VODEbHMAoFzHVt0a3nABiAunYPRVU5LZpDrC0xe6Y 15 | SNGexLvzxpny8cqPnCE0SOA74PiLIyVOnvFHXNT7leQKONkHXt4Z0r86bwpMrU9/ 16 | 9jtMyooO7BKZk8l0JSb4M7817KNFqBZA0rmqR77kaiXlcLMsjizBtFFtKxNu2gD2 17 | fBLPQ6ETB0K79Ft/48hz7hYtRnfhi9mxSePvXRtRyy54b5AgqBQiepvqJ9epy2yR 18 | TKYTZk2M2OIxfqGfwviY0iSuaQ== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /server/src/controllers/LikeController.js: -------------------------------------------------------------------------------- 1 | import Models from '../database/models' 2 | const { PostLike } = Models 3 | 4 | const controller = {} 5 | 6 | controller.likedPost = async (req, res, next) => { 7 | try { 8 | const { id } = req.query 9 | if (!id) return res.status(400).send('id of post is required') 10 | 11 | const liked = await PostLike.findOne({ 12 | where: { userId: req.session.user.id, postId: id }, 13 | }) 14 | 15 | res.status(200).send(!!liked) 16 | } catch (err) { 17 | next(err) 18 | } 19 | } 20 | 21 | controller.likePost = async (req, res, next) => { 22 | try { 23 | const { id } = req.body 24 | if (!id) return res.status(400).send('id of post is required') 25 | 26 | const [postLike, created] = await PostLike.findOrCreate({ 27 | where: { userId: req.session.user.id, postId: id }, 28 | defaults: { 29 | userId: req.session.user.id, 30 | postId: id, 31 | }, 32 | }) 33 | 34 | if (created) { 35 | res.status(201).send(true) 36 | } else { 37 | await postLike.destroy() 38 | res.status(200).send(false) 39 | } 40 | } catch (err) { 41 | next(err) 42 | } 43 | } 44 | 45 | export default controller 46 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish-dev.yml: -------------------------------------------------------------------------------- 1 | name: Publish Dev Image 2 | on: 3 | push: 4 | paths: 5 | - "assets/**" 6 | - "client/**" 7 | - "server/**" 8 | - "nginx/**" 9 | - "*.js" 10 | - "*.json" 11 | - "Dockerfile" 12 | - "entrypoint.sh" 13 | branches: 14 | - "main" 15 | workflow_dispatch: 16 | 17 | jobs: 18 | push_latest_to_registry: 19 | name: Build & Push 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Check out the repo 23 | uses: actions/checkout@v2 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@v1 27 | 28 | - name: Setup Docker buildx 29 | uses: docker/setup-buildx-action@v1.6.0 30 | 31 | - name: Log in to Docker Hub 32 | uses: docker/login-action@v1 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_PASSWORD }} 36 | 37 | - name: Build and push Docker images 38 | uses: docker/build-push-action@v2 39 | with: 40 | push: true 41 | platforms: linux/amd64 42 | file: Dockerfile 43 | context: . 44 | tags: shaneisrael/snapsmaps:develop 45 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Snapsmaps", 3 | "name": "Snapsmaps", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "/android-chrome-192x192.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "screenshots": [ 22 | { 23 | "src": "/screenshots/richer-install-mobile-screenshot.png", 24 | "sizes": "1440x2690", 25 | "type": "image/png", 26 | "form_factor": "narrow" 27 | } 28 | ], 29 | "start_url": "https://localhost:3000", 30 | "launch_handler": { 31 | "client_mode": "focus-existing" 32 | }, 33 | "display": "fullscreen", 34 | "theme_color": "#000000", 35 | "background_color": "#ffffff", 36 | "dir": "auto", 37 | "scope": "https://localhost:3000", 38 | "orientation": "portrait-primary", 39 | "lang": "en", 40 | "categories": ["photo", "social", "travel"], 41 | "description": "Easily share photos with a map pin to friends and family.", 42 | "id": "GcsRdT!73i%x9yP2hkTpCW" 43 | } 44 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240329235510-create-post.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('posts', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | userId: { 11 | allowNull: false, 12 | type: Sequelize.INTEGER, 13 | }, 14 | imageId: { 15 | allowNulL: false, 16 | type: Sequelize.INTEGER, 17 | }, 18 | title: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | }, 22 | likeCount: { 23 | type: Sequelize.INTEGER, 24 | allowNull: false, 25 | defaultValue: 0, 26 | }, 27 | commentCount: { 28 | type: Sequelize.INTEGER, 29 | allowNull: false, 30 | defaultValue: 0, 31 | }, 32 | createdAt: { 33 | allowNull: false, 34 | type: Sequelize.DATE, 35 | }, 36 | updatedAt: { 37 | allowNull: false, 38 | type: Sequelize.DATE, 39 | }, 40 | }) 41 | }, 42 | down: async (queryInterface, Sequelize) => { 43 | await queryInterface.dropTable('posts') 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /server/src/database/migrations/20240909170730-create-collection.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('collections', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | userId: { 11 | allowNull: false, 12 | type: Sequelize.INTEGER, 13 | }, 14 | imageId: { 15 | allowNulL: false, 16 | type: Sequelize.INTEGER, 17 | }, 18 | title: { 19 | type: Sequelize.STRING, 20 | allowNull: false, 21 | }, 22 | public: { 23 | type: Sequelize.BOOLEAN, 24 | allowNull: false, 25 | defaultValue: true, 26 | }, 27 | likeCount: { 28 | type: Sequelize.INTEGER, 29 | allowNull: false, 30 | defaultValue: 0, 31 | }, 32 | createdAt: { 33 | allowNull: false, 34 | type: Sequelize.DATE, 35 | }, 36 | updatedAt: { 37 | allowNull: false, 38 | type: Sequelize.DATE, 39 | }, 40 | }) 41 | }, 42 | down: async (queryInterface, Sequelize) => { 43 | await queryInterface.dropTable('collections') 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /client/src/assets/icons/CameraAltIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export const CameraAltIcon = ({ fill, size, height, width, ...props }) => { 3 | return ( 4 | 12 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /client/src/services/ProfileService.js: -------------------------------------------------------------------------------- 1 | import Api from './Api' 2 | 3 | class ProfileService { 4 | getAuthedProfile() { 5 | return Api().get('/profile') 6 | } 7 | update(profile) { 8 | return Api().putForm('/profile', { ...profile }) 9 | } 10 | getPostHistory() { 11 | return Api().get('/profile/history') 12 | } 13 | getProfileByMention(mention) { 14 | return Api().get('/profile/mention', { params: { mention } }) 15 | } 16 | getMentionPostHistory(mention) { 17 | return Api().get('/profile/history/mention', { params: { mention } }) 18 | } 19 | getCollections() { 20 | return Api().get('/profile/collections') 21 | } 22 | getMentionCollections(mention) { 23 | return Api().get('/profile/collections/mention', { params: { mention } }) 24 | } 25 | follow(mention) { 26 | return Api().post('/profile/follow', { mention }) 27 | } 28 | unfollow(mention) { 29 | return Api().delete('/profile/follow', { params: { mention } }) 30 | } 31 | getFollowers(mention, lastDate) { 32 | return Api().get('/profile/followers', { params: { mention, lastDate } }) 33 | } 34 | getFollowing(mention, lastDate) { 35 | return Api().get('/profile/following', { params: { mention, lastDate } }) 36 | } 37 | } 38 | 39 | const service = new ProfileService() 40 | 41 | export default service 42 | -------------------------------------------------------------------------------- /server/src/middleware/authorize.js: -------------------------------------------------------------------------------- 1 | export const authorize = (req, res, next) => { 2 | if (!req.session.user) { 3 | res.clearCookie('user') 4 | return res.sendStatus(403) 5 | } 6 | 7 | try { 8 | res.cookie( 9 | 'user', 10 | JSON.stringify({ 11 | email: req.session.user.email, 12 | mention: req.session.user.mention, 13 | displayName: req.session.user.displayName, 14 | bio: req.session.user.bio, 15 | image: req.session.user.image, 16 | followersCount: req.session.user.followersCount, 17 | followingCount: req.session.user.followingCount, 18 | isAdmin: req.session.admin, 19 | }), 20 | { sameSite: 'strict' }, 21 | ) 22 | return next() 23 | } catch (err) { 24 | res.clearCookie('user') 25 | return res.sendStatus(403) 26 | } 27 | } 28 | 29 | /** 30 | * When we don't want a 403 to be sent which would cause the un-authenticated 31 | * user to be redirected. But we also can't process the requrest because we 32 | * require an authenticated session. 33 | */ 34 | export const requireSession = (req, res, next) => { 35 | if (!req.session.user) { 36 | return res.sendStatus(204) 37 | } 38 | next() 39 | } 40 | 41 | export const verifyAdmin = (req, res, next) => { 42 | if (req.session.admin) return next() 43 | return res.sendStatus(401) 44 | } 45 | -------------------------------------------------------------------------------- /client/src/components/Dialog/ConfirmationDialog.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react" 2 | import React, { useEffect, useState } from 'react' 3 | 4 | function ConfirmationDialog({ 5 | open, 6 | title, 7 | body, 8 | actionText, 9 | actionColor, 10 | cancelColor, 11 | cancelText, 12 | onAction, 13 | onCancel, 14 | }) { 15 | return ( 16 | 22 | 23 | {(onClose) => ( 24 | <> 25 | {title} 26 | {body && {body}} 27 | 28 | 31 | 34 | 35 | 36 | )} 37 | 38 | 39 | ) 40 | } 41 | 42 | export default ConfirmationDialog 43 | -------------------------------------------------------------------------------- /server/src/database/models/Image.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize' 2 | export default (sequelize, DataTypes) => { 3 | class Image extends Model { 4 | /** 5 | * Helper method for defining associations. 6 | * This method is not a part of Sequelize lifecycle. 7 | * The `models/index` file will call this method automatically. 8 | */ 9 | static associate(models) { 10 | // define association here 11 | const { User, Post } = models 12 | Image.belongsTo(User) 13 | Image.hasOne(Post) 14 | } 15 | } 16 | Image.init( 17 | { 18 | userId: { 19 | type: DataTypes.INTEGER, 20 | allowNull: false, 21 | references: { model: sequelize.models.user, key: 'id' }, 22 | }, 23 | reference: { 24 | type: DataTypes.STRING, 25 | allowNull: false, 26 | }, 27 | width: { 28 | type: DataTypes.INTEGER, 29 | }, 30 | height: { 31 | type: DataTypes.INTEGER, 32 | }, 33 | latitude: { 34 | type: DataTypes.FLOAT, 35 | }, 36 | longitude: { 37 | type: DataTypes.FLOAT, 38 | }, 39 | }, 40 | { 41 | sequelize, 42 | paranoid: true, 43 | modelName: 'image', 44 | defaultScope: { 45 | attributes: { 46 | exclude: ['userId'], 47 | }, 48 | }, 49 | }, 50 | ) 51 | return Image 52 | } 53 | -------------------------------------------------------------------------------- /server/src/database/models/PostLike.js: -------------------------------------------------------------------------------- 1 | import { Model } from 'sequelize' 2 | export default (sequelize, DataTypes) => { 3 | class PostLike extends Model { 4 | /** 5 | * Helper method for defining associations. 6 | * This method is not a part of Sequelize lifecycle. 7 | * The `models/index` file will call this method automatically. 8 | */ 9 | static associate(models) { 10 | // define association here 11 | const { User, Post } = models 12 | PostLike.belongsTo(User) 13 | PostLike.belongsTo(Post) 14 | } 15 | } 16 | PostLike.init( 17 | { 18 | userId: { 19 | type: DataTypes.INTEGER, 20 | allowNull: false, 21 | references: { model: sequelize.models.user, key: 'id' }, 22 | }, 23 | postId: { 24 | type: DataTypes.INTEGER, 25 | allowNull: false, 26 | references: { model: sequelize.models.post, key: 'id' }, 27 | }, 28 | }, 29 | { 30 | hooks: { 31 | afterCreate: (postLike) => { 32 | sequelize.models.post.increment('likeCount', { by: 1, where: { id: postLike.postId } }) 33 | }, 34 | afterDestroy: (postLike) => { 35 | sequelize.models.post.decrement('likeCount', { by: 1, where: { id: postLike.postId } }) 36 | }, 37 | }, 38 | modelName: 'postLike', 39 | sequelize, 40 | }, 41 | ) 42 | return PostLike 43 | } 44 | -------------------------------------------------------------------------------- /server/src/routes/profile.routes.js: -------------------------------------------------------------------------------- 1 | import { Router } from 'express' 2 | import controller from '../controllers/ProfileController.js' 3 | import { authorize } from '../middleware/authorize.js' 4 | import cache from '../middleware/cache.js' 5 | 6 | class ProfileRoutes { 7 | router = Router() 8 | 9 | constructor() { 10 | this.initializeRoutes() 11 | } 12 | 13 | initializeRoutes() { 14 | this.router.get('/profile', authorize, controller.get) 15 | this.router.get('/profile/followers', authorize, cache.privateCache(30), controller.getFollowers) 16 | this.router.get('/profile/following', authorize, cache.privateCache(30), controller.getFollowing) 17 | this.router.get('/profile/mention', controller.getByMention) 18 | this.router.get('/profile/history', authorize, controller.getPostHistory) 19 | this.router.get('/profile/history/mention', cache.publicCache(30), controller.getMentionPostHistory) 20 | this.router.get('/profile/collections', authorize, controller.getCollections) 21 | this.router.get('/profile/collections/mention', cache.publicCache(30), controller.getMentionCollections) 22 | this.router.put('/profile', authorize, controller.update) 23 | this.router.post('/profile/follow', authorize, controller.followProfile) 24 | this.router.delete('/profile/follow', authorize, controller.unfollowProfile) 25 | } 26 | } 27 | 28 | export default new ProfileRoutes().router 29 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'GreatVibes'; 7 | src: local('GreatVibes'), url(./assets/fonts/GreatVibes.otf) format('opentype'); 8 | } 9 | @font-face { 10 | font-family: 'Lobster'; 11 | src: local('Lobster'), url(./assets/fonts/Lobster.ttf) format('truetype'); 12 | } 13 | @font-face { 14 | font-family: 'LobsterTwo'; 15 | src: local('LobsterTwo-Regular'), url(./assets/fonts/LobsterTwo-Regular.ttf) format('truetype'); 16 | } 17 | @font-face { 18 | font-family: 'LobsterTwoBold'; 19 | src: local('LobsterTwo-Bold'), url(./assets/fonts/LobsterTwo-Bold.ttf) format('truetype'); 20 | } 21 | 22 | html { 23 | background: #020617; 24 | } 25 | html, 26 | body, 27 | #root { 28 | height: 100%; 29 | background-color: #000000; 30 | } 31 | 32 | .reactEasyCrop_CropArea { 33 | color: rgba(0, 0, 0, 0.8) !important; 34 | border: 2px solid rgba(255, 255, 255, 0.5) !important; 35 | } 36 | 37 | .ReactCollapse--collapse { 38 | transition: height 500ms; 39 | } 40 | 41 | #header-shape-gradient { 42 | --color-stop: #9350ff; 43 | --color-bot: #0787ff; 44 | } 45 | 46 | .gradient-bg { 47 | fill: url(#header-shape-gradient) #fff; 48 | } 49 | 50 | @keyframes navbarShow { 51 | from { 52 | transform: translateY(-65px); 53 | } 54 | } 55 | @keyframes navbarHide { 56 | to { 57 | transform: translateY(-65px); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/src/database/migrations/20230723020226-create-user.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | up: async (queryInterface, Sequelize) => { 3 | await queryInterface.createTable('users', { 4 | id: { 5 | allowNull: false, 6 | autoIncrement: true, 7 | primaryKey: true, 8 | type: Sequelize.INTEGER, 9 | }, 10 | imageId: { 11 | type: Sequelize.INTEGER, 12 | }, 13 | displayName: { 14 | type: Sequelize.STRING, 15 | allowNull: false, 16 | }, 17 | mention: { 18 | type: Sequelize.STRING, 19 | allowNull: false, 20 | unique: true, 21 | }, 22 | email: { 23 | type: Sequelize.STRING, 24 | allowNull: false, 25 | unique: true, 26 | }, 27 | password: { 28 | type: Sequelize.STRING, 29 | allowNull: false, 30 | }, 31 | bio: { 32 | type: Sequelize.STRING, 33 | }, 34 | verified: { 35 | type: Sequelize.BOOLEAN, 36 | defaultValue: false, 37 | }, 38 | token: { 39 | type: Sequelize.STRING, 40 | }, 41 | createdAt: { 42 | allowNull: false, 43 | type: Sequelize.DATE, 44 | }, 45 | updatedAt: { 46 | allowNull: false, 47 | type: Sequelize.DATE, 48 | }, 49 | }) 50 | }, 51 | down: async (queryInterface, Sequelize) => { 52 | await queryInterface.dropTable('users') 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /client/src/components/Comment/WritePostComment.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, useRef } from 'react' 2 | import { Textarea } from '@heroui/react' 3 | import { CommentService } from '../../services' 4 | import { useAuthed } from '../../hooks/useAuthed' 5 | 6 | function WritePostComment({ postId, onSubmit }) { 7 | const { isAuthenticated } = useAuthed() 8 | const [comment, setComment] = useState('') 9 | 10 | const submitComment = useCallback(async () => { 11 | if (isAuthenticated) { 12 | try { 13 | await CommentService.createPostComment(postId, comment) 14 | setComment('') 15 | onSubmit() 16 | } catch (err) { 17 | console.error(err) 18 | } 19 | } 20 | }, [isAuthenticated, postId, comment]) 21 | 22 | const handleCommentKeydown = useCallback( 23 | (e) => { 24 | if (e.key === 'Enter' && comment) { 25 | submitComment() 26 | } 27 | }, 28 | [comment, submitComment], 29 | ) 30 | 31 | return ( 32 |