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