├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── custom.d.ts ├── env.mjs ├── next.config.mjs ├── package.json ├── paths.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── robots.txt └── static │ ├── default-profile.jpg │ ├── default.jpg │ ├── favicon.ico │ ├── ghost.json │ ├── loader.svg │ ├── logo.png │ ├── search.json │ ├── small-logo.png │ └── square-logo.png ├── scripts └── change-role.ts ├── src ├── components │ ├── ActionButton.tsx │ ├── AnimatedTabs.tsx │ ├── AttachmentList.tsx │ ├── AttachmentPreview.tsx │ ├── AudioPlayer.tsx │ ├── AuthFeedbackMessage.tsx │ ├── Badge.tsx │ ├── BeatLoader.tsx │ ├── Button.tsx │ ├── Carousel.tsx │ ├── Comment.tsx │ ├── CommentActionModal.tsx │ ├── CommentField.tsx │ ├── CommentSection.tsx │ ├── Comments.tsx │ ├── CompactCard.tsx │ ├── ConfirmationModal.tsx │ ├── CreatePoll.tsx │ ├── Dropzone.tsx │ ├── EditAccountModal │ │ ├── Avatar.tsx │ │ ├── EditAccountModal.tsx │ │ ├── UserLink │ │ │ ├── UserLinkField.tsx │ │ │ └── UserLinkPreview.tsx │ │ └── index.ts │ ├── EditCommentForm.tsx │ ├── EditPostForm.tsx │ ├── EmptyMessage.tsx │ ├── ErrorMessage.tsx │ ├── FavoriteButton.tsx │ ├── Field.tsx │ ├── Follows │ │ ├── FollowersModal.tsx │ │ ├── FollowingModal.tsx │ │ └── UserCard.tsx │ ├── HTMLBody.tsx │ ├── Image.tsx │ ├── LikeButton.tsx │ ├── LikeCount.tsx │ ├── LinkInput.tsx │ ├── LinkPreview.tsx │ ├── MarkdownEditor.tsx │ ├── MetaTags.tsx │ ├── Modal.tsx │ ├── PollView │ │ ├── PollOption.tsx │ │ └── PollView.tsx │ ├── Popover │ │ ├── Popover.tsx │ │ ├── PopoverItem.tsx │ │ └── index.ts │ ├── PostCard.tsx │ ├── PostDetails.tsx │ ├── PostModal.tsx │ ├── PreviewMediaModal.tsx │ ├── RouterProgressBar.tsx │ ├── SearchInput.tsx │ ├── Section.tsx │ ├── SelectTags.tsx │ ├── ShouldRender.tsx │ ├── Skeleton.tsx │ ├── SlideOver.tsx │ ├── Spinner.tsx │ ├── Tab.tsx │ ├── Tag.tsx │ ├── TagHoverCard.tsx │ ├── TagList.tsx │ ├── TagPreview.tsx │ ├── TagSection.tsx │ ├── TextInput.tsx │ ├── UpsertTagModal │ │ ├── TagImageInput.tsx │ │ ├── TagImagePreview.tsx │ │ ├── UpsertTagModal.tsx │ │ └── index.ts │ ├── UserPageList.tsx │ └── UserPreview.tsx ├── hooks │ ├── aws │ │ └── useUploadTagImagesToS3.ts │ ├── useClickOutside │ │ ├── index.ts │ │ ├── useClickOutside.ts │ │ ├── useEventListener.ts │ │ └── useIsomorphicLayoutEffect.ts │ ├── useFilterContent.ts │ ├── useGetWindowDimensions.ts │ ├── useMediaQuery.ts │ ├── useOnScreen.ts │ └── useTabs.ts ├── layouts │ └── MainLayout │ │ ├── Header │ │ ├── Header.tsx │ │ ├── NotificationDropdown │ │ │ ├── NotificationCard.tsx │ │ │ └── NotificationDropdown.tsx │ │ ├── SearchDropdown │ │ │ ├── Dropdown.tsx │ │ │ ├── SearchDropdown.tsx │ │ │ └── index.ts │ │ ├── TagsDropdown │ │ │ └── TagsDropdown.tsx │ │ └── index.ts │ │ ├── MainLayout.tsx │ │ ├── Sidebar │ │ ├── Sidebar.tsx │ │ ├── ThemeButton.tsx │ │ └── index.ts │ │ └── index.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth].ts │ │ └── trpc │ │ │ ├── attachments │ │ │ └── [trpc].ts │ │ │ ├── comments │ │ │ └── [trpc].ts │ │ │ ├── likes │ │ │ └── [trpc].ts │ │ │ ├── notification │ │ │ └── [trpc].ts │ │ │ ├── posts │ │ │ └── [trpc].ts │ │ │ ├── scraper │ │ │ └── [trpc].ts │ │ │ ├── search │ │ │ └── [trpc].ts │ │ │ ├── tags │ │ │ └── [trpc].ts │ │ │ └── users │ │ │ └── [trpc].ts │ ├── auth │ │ ├── error.tsx │ │ ├── new-user.tsx │ │ ├── signin.tsx │ │ ├── signout.tsx │ │ └── verify-request.tsx │ ├── index.tsx │ ├── posts │ │ ├── [postId].tsx │ │ ├── favorited.tsx │ │ ├── feed.tsx │ │ ├── following.tsx │ │ ├── liked.tsx │ │ ├── new.tsx │ │ └── tags │ │ │ ├── [tagId].tsx │ │ │ ├── index.tsx │ │ │ └── subscribed.tsx │ ├── search.tsx │ ├── terms │ │ ├── conduct.tsx │ │ └── privacy.tsx │ └── users │ │ └── [userId] │ │ └── index.tsx ├── schema │ ├── attachment.schema.ts │ ├── comment.schema.ts │ ├── constants.ts │ ├── like.schema.ts │ ├── notification.schema.ts │ ├── post.schema.ts │ ├── scraper.schema.ts │ ├── search.schema.ts │ ├── tag.schema.ts │ └── user.schema.ts ├── server │ ├── config │ │ └── aws.ts │ ├── router │ │ ├── app.router.ts │ │ ├── attachments │ │ │ ├── _router.ts │ │ │ ├── createPresignedAvatarUrl.handler.ts │ │ │ ├── createPresignedPostBodyUrl.handler.ts │ │ │ ├── createPresignedTagUrl.handler.ts │ │ │ ├── createPresignedUrl.handler.ts │ │ │ └── deleteAttachment.handler.ts │ │ ├── comment │ │ │ ├── _router.ts │ │ │ ├── addComment.handler.ts │ │ │ ├── allComments.handler.ts │ │ │ ├── deleteComment.handler.ts │ │ │ ├── updateComment.handler.ts │ │ │ └── userComments.handler.ts │ │ ├── like │ │ │ ├── _router.ts │ │ │ └── likePost.handler.ts │ │ ├── notification │ │ │ ├── _router.ts │ │ │ ├── getAll.handler.ts │ │ │ ├── markAsRead.handler.ts │ │ │ └── totalUnreads.handler.ts │ │ ├── post │ │ │ ├── _router.ts │ │ │ ├── all.handler.ts │ │ │ ├── byTags.handler.ts │ │ │ ├── createPost.handler.ts │ │ │ ├── deletePost.handler.ts │ │ │ ├── favoritePost.handler.ts │ │ │ ├── following.handler.ts │ │ │ ├── getFavoritePosts.handler.ts │ │ │ ├── getLikedPosts.handler.ts │ │ │ ├── singlePost.handler.ts │ │ │ ├── subscribed.handler.ts │ │ │ ├── updatePost.handler.ts │ │ │ ├── voteOnPoll.handler.ts │ │ │ └── yourFeed.handler.ts │ │ ├── scraper │ │ │ ├── _router.ts │ │ │ └── scrapeLink.handler.ts │ │ ├── search │ │ │ ├── _router.ts │ │ │ └── byType.handler.ts │ │ ├── tag │ │ │ ├── _router.ts │ │ │ ├── all.handler.ts │ │ │ ├── delete.handler.ts │ │ │ ├── singleTag.handler.ts │ │ │ ├── subscribe.handler.ts │ │ │ ├── subscribed.handler.ts │ │ │ └── update.handler.ts │ │ └── user │ │ │ ├── _router.ts │ │ │ ├── deleteUser.handler.ts │ │ │ ├── followUser.handler.ts │ │ │ ├── getFollowers.handler.ts │ │ │ ├── getFollowing.handler.ts │ │ │ ├── singleUser.handler.ts │ │ │ └── updateProfile.handler.ts │ ├── ssgHepers.ts │ ├── trpc.ts │ └── utils │ │ ├── auth.ts │ │ ├── deleteChildComments.ts │ │ ├── formatComments.ts │ │ ├── formatDate.ts │ │ ├── formatPosts.ts │ │ ├── getFiltersByInput.ts │ │ ├── index.ts │ │ ├── isStringEmpty.ts │ │ └── markdownToHtml.ts ├── styles │ └── globals.scss ├── types │ └── next-auth.d.ts └── utils │ ├── aws │ ├── generateS3Url.ts │ └── uploadFileToS3.ts │ ├── constants.ts │ ├── convertToMB.ts │ ├── createNextApiHandler.ts │ ├── getUserDisplayName.ts │ ├── parseTagPayload.ts │ ├── prisma.ts │ ├── trpc.ts │ └── types.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | ## If any of these env. variables are empty, the build will fail. 2 | 3 | DATABASE_URL= 4 | 5 | GOOGLE_CLIENT_ID= 6 | GOOGLE_CLIENT_SECRET= 7 | 8 | DISCORD_CLIENT_ID= 9 | DISCORD_CLIENT_SECRET= 10 | 11 | GITHUB_SECRET= 12 | GITHUB_ID= 13 | 14 | ## This value is optional and should be used on your main deployment 15 | ## The app automatically uses the `NEXT_PUBLIC_VERCEL_URL` from Vercel, 16 | ## which is the domain of your current deployment. (eg: `t3-blog-oef432-leojuriolli.vercel.app`) 17 | ## `NEXT_PUBLIC_VERCEL_URL` changes on every commit. 18 | 19 | ## On your main branch you will want to use a set domain value, like your main branch's default domain. 20 | ## (eg: `t3-blog-pi.vercel.app`) 21 | NEXT_PUBLIC_BYPASS_URL= 22 | 23 | ## 2 accepted values: production or development 24 | NEXT_PUBLIC_ENVIRONMENT= 25 | 26 | ## For the mail sign-in option 27 | MAILER_PASSWORD= 28 | MAILER_USER= 29 | EMAIL_SERVER_HOST= 30 | EMAIL_SERVER_PORT= 31 | 32 | 33 | ## Should point to your main branch's url 34 | ## My value on Vercel: https://t3-blog-pi.vercel.app/ 35 | NEXTAUTH_URL=http://localhost:3000/ 36 | 37 | ## Any secret value. eg: 123456789 38 | NEXTAUTH_SECRET= 39 | 40 | AWS_ACCESS_KEY_ID= 41 | AWS_SECRET_ACCESS_KEY= 42 | AWS_S3_ATTACHMENTS_BUCKET_NAME= 43 | AWS_S3_AVATARS_BUCKET_NAME= 44 | 45 | NEXT_PUBLIC_AWS_S3_AVATARS_BUCKET_NAME= 46 | NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME= 47 | NEXT_PUBLIC_AWS_S3_POST_BODY_BUCKET_NAME= 48 | 49 | ## Change if you want: 50 | NEXT_PUBLIC_AWS_REGION=sa-east-1 51 | NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE=10485760 52 | NEXT_PUBLIC_UPLOAD_MAX_NUMBER_OF_FILES=4 53 | NEXT_PUBLIC_UPLOADING_TIME_LIMIT=30 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: ["*"] 6 | merge_group: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Install modules 14 | run: yarn 15 | - name: Run ESLint 16 | run: npx eslint . --ext .js,.jsx,.ts,.tsx 17 | 18 | prettier: 19 | runs-on: ubuntu-latest 20 | name: Run Prettier 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | 26 | - name: Install Node.js 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: 16 30 | 31 | - name: Install dependencies 32 | run: yarn 33 | 34 | - run: npx prettier --check *.{js,jsx,ts,tsx} --ignore-unknown --no-error-on-unmatched-pattern 35 | 36 | tsc: 37 | runs-on: ubuntu-latest 38 | name: Run Typechecker 39 | steps: 40 | - uses: actions/checkout@v3 41 | with: 42 | fetch-depth: 0 43 | 44 | - name: Install Node.js 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: 16 48 | 49 | - name: Install dependencies 50 | run: yarn 51 | 52 | - run: yarn typecheck 53 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | .env 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | .pnpm-debug.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn typecheck && yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | res 5 | coverage 6 | .swc 7 | .vscode 8 | .husky 9 | .next -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "singleAttributePerLine": false, 8 | "bracketSameLine": false, 9 | "jsxBracketSameLine": false, 10 | "jsxSingleQuote": false, 11 | "printWidth": 80, 12 | "proseWrap": "preserve", 13 | "quoteProps": "as-needed", 14 | "requirePragma": false, 15 | "semi": true, 16 | "singleQuote": false, 17 | "tabWidth": 2, 18 | "trailingComma": "es5", 19 | "useTabs": false, 20 | "plugins": ["prettier-plugin-tailwindcss"] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Ignore @apply rule warning on globals.scss 3 | "scss.lint.unknownAtRules": "ignore" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Leonardo Juriolli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "markdown-it-task-lists"; 2 | declare module "react-syntax-highlighter"; 3 | declare module "react-syntax-highlighter/dist/cjs/styles/prism"; 4 | declare module "rehype-truncate"; 5 | -------------------------------------------------------------------------------- /env.mjs: -------------------------------------------------------------------------------- 1 | import { createEnv } from "@t3-oss/env-nextjs"; 2 | import { z } from "zod"; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | DATABASE_URL: z.string(), 7 | GOOGLE_CLIENT_ID: z.string(), 8 | GOOGLE_CLIENT_SECRET: z.string(), 9 | DISCORD_CLIENT_ID: z.string(), 10 | DISCORD_CLIENT_SECRET: z.string(), 11 | GITHUB_ID: z.string(), 12 | GITHUB_SECRET: z.string(), 13 | MAILER_PASSWORD: z.string(), 14 | MAILER_USER: z.string(), 15 | EMAIL_SERVER_HOST: z.string(), 16 | EMAIL_SERVER_PORT: z.string(), 17 | NEXTAUTH_URL: z.string().optional(), 18 | NEXTAUTH_SECRET: z.string(), 19 | AWS_ACCESS_KEY_ID: z.string(), 20 | AWS_SECRET_ACCESS_KEY: z.string(), 21 | AWS_S3_ATTACHMENTS_BUCKET_NAME: z.string(), 22 | }, 23 | client: { 24 | NEXT_PUBLIC_BYPASS_URL: z.string().optional(), 25 | NEXT_PUBLIC_AWS_S3_POST_BODY_BUCKET_NAME: z.string(), 26 | NEXT_PUBLIC_AWS_S3_AVATARS_BUCKET_NAME: z.string(), 27 | NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME: z.string(), 28 | NEXT_PUBLIC_AWS_REGION: z.string().default("sa-east-1"), 29 | NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE: z.string().default("10485760"), 30 | NEXT_PUBLIC_UPLOADING_TIME_LIMIT: z.string().default("30"), 31 | NEXT_PUBLIC_UPLOAD_MAX_NUMBER_OF_FILES: z.string().default("4"), 32 | }, 33 | runtimeEnv: { 34 | DATABASE_URL: process.env.DATABASE_URL, 35 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, 36 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, 37 | GITHUB_SECRET: process.env.GITHUB_SECRET, 38 | GITHUB_ID: process.env.GITHUB_ID, 39 | DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, 40 | DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, 41 | MAILER_PASSWORD: process.env.MAILER_PASSWORD, 42 | MAILER_USER: process.env.MAILER_USER, 43 | EMAIL_SERVER_HOST: process.env.EMAIL_SERVER_HOST, 44 | EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT, 45 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 46 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 47 | AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, 48 | AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, 49 | AWS_S3_ATTACHMENTS_BUCKET_NAME: process.env.AWS_S3_ATTACHMENTS_BUCKET_NAME, 50 | NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME: 51 | process.env.NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME, 52 | NEXT_PUBLIC_BYPASS_URL: process.env.NEXT_PUBLIC_BYPASS_URL, 53 | NEXT_PUBLIC_AWS_S3_POST_BODY_BUCKET_NAME: 54 | process.env.NEXT_PUBLIC_AWS_S3_POST_BODY_BUCKET_NAME, 55 | NEXT_PUBLIC_AWS_S3_AVATARS_BUCKET_NAME: 56 | process.env.NEXT_PUBLIC_AWS_S3_AVATARS_BUCKET_NAME, 57 | NEXT_PUBLIC_AWS_REGION: process.env.NEXT_PUBLIC_AWS_REGION, 58 | NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE: 59 | process.env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE, 60 | NEXT_PUBLIC_UPLOADING_TIME_LIMIT: 61 | process.env.NEXT_PUBLIC_UPLOADING_TIME_LIMIT, 62 | NEXT_PUBLIC_UPLOAD_MAX_NUMBER_OF_FILES: 63 | process.env.NEXT_PUBLIC_UPLOAD_MAX_NUMBER_OF_FILES, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import "./env.mjs"; 2 | 3 | /** 4 | * @template {import('next').NextConfig} T 5 | * @param {T} config - A generic parameter that flows through to the return type 6 | * @constraint {{import('next').NextConfig}} 7 | */ 8 | const nextConfig = { 9 | reactStrictMode: true, 10 | swcMinify: true, 11 | experimental: { 12 | scrollRestoration: true, 13 | newNextLinkBehavior: true, 14 | images: { 15 | allowFutureImage: true, 16 | }, 17 | }, 18 | images: { 19 | domains: [ 20 | "cdn.discordapp.com", 21 | "lh3.googleusercontent.com", 22 | "avatars.githubusercontent.com", 23 | // Add your own bucket urls here: 24 | "t3-images-bucket.s3.sa-east-1.amazonaws.com", 25 | "t3-images-bucket.s3.amazonaws.com", 26 | "t3-avatars-bucket.s3.sa-east-1.amazonaws.com", 27 | "t3-avatars-bucket.s3.amazonaws.com", 28 | "t3-tag-images-bucket.s3.sa-east-1.amazonaws.com", 29 | ], 30 | }, 31 | }; 32 | 33 | export default nextConfig; 34 | -------------------------------------------------------------------------------- /paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@pages/*": ["src/pages/*"], 6 | "@styles/*": ["src/styles/*"], 7 | "@utils/*": ["src/utils/*"], 8 | "@layouts/*": ["src/layouts/*"], 9 | "@components/*": ["src/components/*"], 10 | "@server/*": ["src/server/*"], 11 | "@public/*": ["public/*"], 12 | "@api/*": ["src/api/*"], 13 | "@config/*": ["src/config/*"], 14 | "@hooks/*": ["src/hooks/*"], 15 | "@package": ["package.json"], 16 | "@env": ["env.mjs"], 17 | "@schema/*": ["src/schema/*"] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | ...(process.env.NEXT_PUBLIC_ENVIRONMENT === "production" 6 | ? { cssnano: {} } 7 | : {}), 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: Googlebot 2 | Disallow: /auth/signin/ 3 | Disallow: /auth/signout/ 4 | Disallow: /auth/verify-request/ 5 | Disallow: /auth/error/ 6 | Disallow: /posts/new/ 7 | Disallow: /posts/liked/ 8 | Disallow: /posts/following/ 9 | 10 | User-agent: * 11 | Allow: / -------------------------------------------------------------------------------- /public/static/default-profile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/default-profile.jpg -------------------------------------------------------------------------------- /public/static/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/default.jpg -------------------------------------------------------------------------------- /public/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/favicon.ico -------------------------------------------------------------------------------- /public/static/loader.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/logo.png -------------------------------------------------------------------------------- /public/static/small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/small-logo.png -------------------------------------------------------------------------------- /public/static/square-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leojuriolli7/t3-blog/68998ebdc94697c31de2ec3ead92929025e955f9/public/static/square-logo.png -------------------------------------------------------------------------------- /scripts/change-role.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const id = process.argv[2]; 4 | const role = process.argv[3]; 5 | 6 | const usage = () => { 7 | console.log("yarn change-role "); 8 | process.exit(1); 9 | }; 10 | 11 | const main = async () => { 12 | if ( 13 | id === undefined || 14 | role === undefined || 15 | !["user", "admin"].includes(role) 16 | ) { 17 | usage(); 18 | } 19 | 20 | const prisma = new PrismaClient(); 21 | const user = await prisma.user.findUnique({ 22 | where: { 23 | id, 24 | }, 25 | }); 26 | 27 | if (!user) { 28 | usage(); 29 | } 30 | 31 | await prisma.user.update({ 32 | where: { 33 | id: user?.id, 34 | }, 35 | data: { 36 | role: role === "admin" ? role : undefined, 37 | }, 38 | }); 39 | 40 | console.log(`Changed ${id as string} to have role ${role as string}`); 41 | }; 42 | 43 | main().catch((e) => { 44 | console.error(e); 45 | process.exit(1); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { AiFillDelete, AiFillEdit } from "react-icons/ai"; 3 | import { MdClose } from "react-icons/md"; 4 | 5 | type Actions = "delete" | "edit" | "close"; 6 | 7 | type Props = React.ButtonHTMLAttributes & { 8 | action: Actions; 9 | }; 10 | 11 | const iconProps = { 12 | className: "text-emerald-500 xl:w-[23px] xl:h-[23px] w-5 h-5", 13 | }; 14 | 15 | const icons: Record = { 16 | close: , 17 | delete: , 18 | edit: , 19 | }; 20 | 21 | const ActionButton: React.FC = (props) => { 22 | const { action, className = "" } = props; 23 | 24 | return ( 25 | 31 | ); 32 | }; 33 | 34 | export default ActionButton; 35 | -------------------------------------------------------------------------------- /src/components/AuthFeedbackMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BsCheckCircleFill, BsExclamationCircleFill } from "react-icons/bs"; 3 | import ShouldRender from "./ShouldRender"; 4 | 5 | type Props = { 6 | message: string; 7 | icon?: boolean; 8 | type?: "error" | "success"; 9 | }; 10 | 11 | const AuthFeedbackMessage: React.FC = ({ 12 | message, 13 | icon = true, 14 | type = "error", 15 | }) => { 16 | const isError = type === "error"; 17 | 18 | return ( 19 | 20 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |

{message}

34 |
35 |
36 | ); 37 | }; 38 | 39 | export default AuthFeedbackMessage; 40 | -------------------------------------------------------------------------------- /src/components/Badge.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | 4 | type Props = React.HTMLAttributes; 5 | 6 | export const Badge: React.FC = (props) => { 7 | return ( 8 | 15 | {props?.children} 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/BeatLoader.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | 3 | export default function ThreeDotsFade({ 4 | width = 24, 5 | height = 24, 6 | dur = "0.75s", 7 | className = "fill-black", 8 | }: SVGProps): JSX.Element { 9 | return ( 10 | 17 | 18 | 26 | 27 | 28 | 35 | 36 | 37 | 45 | 46 | 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/CommentActionModal.tsx: -------------------------------------------------------------------------------- 1 | import type { CommentWithChildren } from "@utils/types"; 2 | import { Dispatch, SetStateAction } from "react"; 3 | import Comment from "./Comment"; 4 | import CommentField from "./CommentField"; 5 | import EditCommentForm from "./EditCommentForm"; 6 | import { Modal } from "./Modal"; 7 | import ShouldRender from "./ShouldRender"; 8 | 9 | type Props = { 10 | parentComment?: CommentWithChildren; 11 | editing?: boolean; 12 | openState: [boolean, Dispatch>]; 13 | }; 14 | 15 | const CommentActionModal: React.FC = ({ 16 | openState, 17 | parentComment, 18 | editing, 19 | }) => { 20 | const [, setOpen] = openState; 21 | 22 | const closeModal = () => setOpen(false); 23 | 24 | return ( 25 | 26 |
27 | 28 | 29 | 30 | 31 |
32 | 33 | 37 | 38 | 39 | 40 | 41 | 42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default CommentActionModal; 49 | -------------------------------------------------------------------------------- /src/components/CommentSection.tsx: -------------------------------------------------------------------------------- 1 | import { trpc } from "@utils/trpc"; 2 | import { useRouter } from "next/router"; 3 | import React, { useEffect } from "react"; 4 | import CommentField from "./CommentField"; 5 | import Comments from "./Comments"; 6 | import ShouldRender from "./ShouldRender"; 7 | 8 | const CommentSection: React.FC = () => { 9 | const router = useRouter(); 10 | const postId = router.query.postId as string; 11 | 12 | const { data: comments } = trpc.comments.allComments.useQuery( 13 | { 14 | postId, 15 | }, 16 | { 17 | refetchOnWindowFocus: false, 18 | enabled: !!postId, 19 | } 20 | ); 21 | 22 | // Scroll down to highlighted comment if query parameter exists. 23 | const highlightedComment = router.query.highlightedComment as string; 24 | const commentElement = document.getElementById(highlightedComment); 25 | 26 | useEffect(() => { 27 | if (!!commentElement) { 28 | commentElement?.scrollIntoView({ behavior: "smooth" }); 29 | const ringClasses = "ring ring-gray-400 dark:ring-white"; 30 | 31 | commentElement.className = `${commentElement.className} ${ringClasses}`; 32 | 33 | // Remove highlight after 4 seconds. 34 | setTimeout(() => { 35 | commentElement.className = commentElement.className.replace( 36 | ringClasses, 37 | "" 38 | ); 39 | }, 4000); 40 | } 41 | }, [commentElement]); 42 | 43 | return ( 44 |
45 | 46 | 47 |
48 | 49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | export default CommentSection; 56 | -------------------------------------------------------------------------------- /src/components/Comments.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { CommentWithChildren } from "@utils/types"; 3 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 4 | import Comment from "./Comment"; 5 | import clsx from "clsx"; 6 | 7 | type Props = { comments?: Array; collapsed?: boolean }; 8 | 9 | const Comments: React.FC = ({ comments, collapsed }) => { 10 | const [parentRef] = useAutoAnimate(); 11 | 12 | return ( 13 |
20 | {comments?.map((comment) => { 21 | return ; 22 | })} 23 |
24 | ); 25 | }; 26 | 27 | export default Comments; 28 | -------------------------------------------------------------------------------- /src/components/CompactCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { TaggedPosts } from "@utils/types"; 3 | import clsx from "clsx"; 4 | import getUserDisplayName from "@utils/getUserDisplayName"; 5 | import { useContextualRouting } from "next-use-contextual-routing"; 6 | import LikeCount from "./LikeCount"; 7 | import Link from "next/link"; 8 | import ShouldRender from "./ShouldRender"; 9 | import Skeleton from "./Skeleton"; 10 | import HTMLBody from "./HTMLBody"; 11 | 12 | type Props = { 13 | slide?: boolean; 14 | post?: TaggedPosts; 15 | loading?: boolean; 16 | bgClass?: string; 17 | }; 18 | 19 | const CompactCard: React.FC = ({ slide, post, loading, bgClass }) => { 20 | const { makeContextualHref } = useContextualRouting(); 21 | const username = getUserDisplayName(post?.user); 22 | 23 | return ( 24 | 46 |
47 | 48 |

49 | {post?.title} 50 |

51 |
52 | 53 | 54 | 55 | 56 | 57 | 62 | {post?.body} 63 | 64 |
65 | 66 |
67 |
68 | 69 | 70 |
71 | 72 | 73 |
74 |

75 | By {username} 76 |

77 |
78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | export default CompactCard; 85 | -------------------------------------------------------------------------------- /src/components/ConfirmationModal.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useCallback } from "react"; 2 | import { BsExclamationCircleFill } from "react-icons/bs"; 3 | import Button from "./Button"; 4 | import { Modal } from "./Modal"; 5 | import ShouldRender from "./ShouldRender"; 6 | 7 | type Props = { 8 | title: string; 9 | description?: string; 10 | confirmationLabel?: string; 11 | onConfirm?: () => void; 12 | onCancel?: () => void; 13 | openState: [boolean, Dispatch>]; 14 | icon?: React.ReactNode; 15 | loading?: boolean; 16 | }; 17 | 18 | const ConfirmationModal: React.FC = ({ 19 | title, 20 | description = "This action is permanent and cannot be undone!", 21 | confirmationLabel, 22 | onCancel, 23 | onConfirm, 24 | openState, 25 | icon, 26 | loading, 27 | }) => { 28 | const [, setOpen] = openState; 29 | 30 | const handleClickCancel = useCallback(() => { 31 | if (onCancel) return onCancel?.(); 32 | 33 | if (!onCancel) setOpen(false); 34 | }, [onCancel, setOpen]); 35 | 36 | return ( 37 | 38 |
39 |
40 |
41 | 42 | 43 | 44 | 45 | {icon} 46 |
47 | 48 |
49 | 53 | {title} 54 | 55 |
56 |

57 | {description} 58 |

59 |
60 |
61 |
62 |
63 | 71 | 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default ConfirmationModal; 86 | -------------------------------------------------------------------------------- /src/components/EditAccountModal/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import { UpdateUserInput } from "@schema/user.schema"; 2 | import Image from "@components/Image"; 3 | import { ChangeEvent, useEffect, useState } from "react"; 4 | import { useFormContext } from "react-hook-form"; 5 | import { MdAdd, MdOutlineAddAPhoto } from "react-icons/md"; 6 | 7 | type Props = { 8 | image?: string | null; 9 | }; 10 | 11 | const Avatar: React.FC = ({ image }) => { 12 | const [currentImage, setCurrentImage] = useState( 13 | image || undefined 14 | ); 15 | const { setValue } = useFormContext(); 16 | 17 | const onFileChange = (e: ChangeEvent) => { 18 | const selectedFile = e.target?.files?.[0]; 19 | setValue("avatar", selectedFile || undefined); 20 | 21 | if (selectedFile) { 22 | setCurrentImage(URL.createObjectURL(selectedFile)); 23 | } 24 | }; 25 | 26 | useEffect(() => setCurrentImage(image || undefined), [image]); 27 | 28 | return ( 29 | <> 30 |
31 | 56 |
57 | 58 | 65 | 66 | ); 67 | }; 68 | 69 | export default Avatar; 70 | -------------------------------------------------------------------------------- /src/components/EditAccountModal/UserLink/UserLinkPreview.tsx: -------------------------------------------------------------------------------- 1 | import ShouldRender from "@components/ShouldRender"; 2 | import { urlSchema } from "@schema/user.schema"; 3 | import { useState } from "react"; 4 | import { infer as zodInfer } from "zod"; 5 | 6 | type PreviewProps = { 7 | data: zodInfer; 8 | }; 9 | 10 | const UserLinkPreview: React.FC = ({ data }) => { 11 | const [hovering, setHovering] = useState(false); 12 | 13 | return ( 14 | 15 | 16 |
setHovering(true)} 18 | onMouseLeave={() => setHovering(false)} 19 | className="mt-2 flex h-[40px] w-full cursor-pointer items-center gap-2 rounded-md bg-white p-2 ring-1 ring-inset ring-gray-300 hover:bg-gray-100 dark:bg-zinc-700 dark:ring-0 dark:hover:bg-zinc-600" 20 | > 21 | {/* eslint-disable-next-line @next/next/no-img-element */} 22 | {data?.title} 23 | 24 |

25 | {hovering ? data?.url : data?.title} 26 |

27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default UserLinkPreview; 34 | -------------------------------------------------------------------------------- /src/components/EditAccountModal/index.ts: -------------------------------------------------------------------------------- 1 | import EditAccountModal from "./EditAccountModal"; 2 | 3 | export default EditAccountModal; 4 | -------------------------------------------------------------------------------- /src/components/EditCommentForm.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { useForm } from "react-hook-form"; 3 | import { 4 | UpdateCommentInput, 5 | updateCommentSchema, 6 | } from "@schema/comment.schema"; 7 | import type { CommentWithChildren } from "@utils/types"; 8 | import { trpc } from "@utils/trpc"; 9 | import { toast } from "react-toastify"; 10 | import { useRouter } from "next/router"; 11 | import { zodResolver } from "@hookform/resolvers/zod"; 12 | import MarkdownEditor from "./MarkdownEditor"; 13 | import Field from "./Field"; 14 | import Button from "./Button"; 15 | 16 | type Props = { 17 | comment?: CommentWithChildren; 18 | onFinish: () => void; 19 | }; 20 | 21 | const EditCommentForm: React.FC = ({ comment, onFinish }) => { 22 | const utils = trpc.useContext(); 23 | 24 | const router = useRouter(); 25 | const postId = router.query.postId as string; 26 | 27 | const uploadingImagesState = useState(false); 28 | const [uploading] = uploadingImagesState; 29 | 30 | const { handleSubmit, control, formState } = useForm({ 31 | resolver: zodResolver(updateCommentSchema), 32 | shouldFocusError: false, 33 | defaultValues: { 34 | body: comment?.markdownBody, 35 | commentId: comment?.id, 36 | }, 37 | }); 38 | 39 | const { errors } = formState; 40 | 41 | const { 42 | mutate: update, 43 | isLoading: updating, 44 | error: updateError, 45 | } = trpc.comments.updateComment.useMutation({ 46 | onSuccess: () => { 47 | utils.comments.allComments.invalidate({ 48 | postId, 49 | }); 50 | }, 51 | }); 52 | 53 | const onSubmit = useCallback( 54 | (values: UpdateCommentInput) => { 55 | update({ 56 | commentId: comment?.id as string, 57 | body: values.body, 58 | }); 59 | 60 | onFinish(); 61 | }, 62 | [update, comment, onFinish] 63 | ); 64 | 65 | useEffect(() => { 66 | if (updateError) { 67 | toast.error(updateError?.message); 68 | } 69 | }, [updateError]); 70 | 71 | return ( 72 |
73 | 74 | 81 | 82 | 83 | 91 |
92 | ); 93 | }; 94 | 95 | export default EditCommentForm; 96 | -------------------------------------------------------------------------------- /src/components/EmptyMessage.tsx: -------------------------------------------------------------------------------- 1 | import Lottie from "react-lottie"; 2 | import NoPostsAnimation from "@public/static/ghost.json"; 3 | import Link from "next/link"; 4 | import ShouldRender from "./ShouldRender"; 5 | 6 | type Props = { 7 | message: string; 8 | redirect?: string; 9 | redirectMessage?: string; 10 | hideRedirect?: boolean; 11 | small?: boolean; 12 | }; 13 | 14 | const LOTTIE_OPTIONS = { 15 | loop: true, 16 | autoplay: true, 17 | animationData: NoPostsAnimation, 18 | }; 19 | 20 | const EmptyMessage: React.FC = ({ 21 | message, 22 | redirect, 23 | redirectMessage = "Go back to home", 24 | hideRedirect = false, 25 | small = false, 26 | }) => { 27 | return ( 28 |
29 | 34 |

{message}

35 | 36 | 40 | {redirectMessage} 41 | 42 | 43 |
44 | ); 45 | }; 46 | 47 | export default EmptyMessage; 48 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { FieldError } from "react-hook-form"; 2 | import ShouldRender from "./ShouldRender"; 3 | 4 | type Props = { 5 | error?: FieldError; 6 | }; 7 | 8 | const ErrorMessage: React.FC = ({ error }) => { 9 | return ( 10 | 11 |

12 | {error?.message} 13 |

14 |
15 | ); 16 | }; 17 | 18 | export default ErrorMessage; 19 | -------------------------------------------------------------------------------- /src/components/FavoriteButton.tsx: -------------------------------------------------------------------------------- 1 | import { AiFillHeart } from "react-icons/ai"; 2 | 3 | type Props = { 4 | disabled: boolean; 5 | onClick: () => void; 6 | favoritedByMe?: boolean; 7 | }; 8 | 9 | const FavoriteButton: React.FC = (props) => { 10 | const { favoritedByMe, disabled, onClick } = props; 11 | 12 | const buttonDescription = favoritedByMe 13 | ? "Unfavorite this post" 14 | : "Favorite this post"; 15 | 16 | return ( 17 | 33 | ); 34 | }; 35 | 36 | export default FavoriteButton; 37 | -------------------------------------------------------------------------------- /src/components/Field.tsx: -------------------------------------------------------------------------------- 1 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 | import clsx from "clsx"; 3 | import { FieldError } from "react-hook-form"; 4 | import ErrorMessage from "./ErrorMessage"; 5 | import ShouldRender from "./ShouldRender"; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | error?: FieldError; 10 | label?: string; 11 | description?: string; 12 | descriptionClasses?: string; 13 | required?: boolean; 14 | }; 15 | 16 | const Field: React.FC = ({ 17 | children, 18 | error, 19 | label, 20 | description, 21 | required, 22 | descriptionClasses = "text-sm", 23 | }) => { 24 | const [parentRef] = useAutoAnimate(); 25 | return ( 26 |
27 | 28 |
29 | 39 | 40 |

46 | {description} 47 |

48 |
49 |
50 |
51 | 52 |
{children}
53 |
54 | ); 55 | }; 56 | 57 | export default Field; 58 | -------------------------------------------------------------------------------- /src/components/Follows/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import type { FollowingUser } from "@utils/types"; 2 | import Image from "@components/Image"; 3 | import { useRouter } from "next/router"; 4 | import React, { useCallback } from "react"; 5 | import ShouldRender from "../ShouldRender"; 6 | import Skeleton from "../Skeleton"; 7 | 8 | type Props = { 9 | user?: FollowingUser; 10 | loading: boolean; 11 | onClickCard?: () => void; 12 | }; 13 | 14 | const UserCard: React.FC = ({ loading, user, onClickCard }) => { 15 | const router = useRouter(); 16 | 17 | const handleClickCard = useCallback(() => { 18 | if (onClickCard) onClickCard(); 19 | 20 | setTimeout(() => router.push(`/users/${user?.id}`), 200); 21 | }, [user, router, onClickCard]); 22 | 23 | return ( 24 |
30 |
31 | {user?.name 39 |
40 |
41 | 42 |

{user?.name}

43 |
44 | 45 | 46 | 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default UserCard; 53 | -------------------------------------------------------------------------------- /src/components/HTMLBody.tsx: -------------------------------------------------------------------------------- 1 | import ShouldRender from "./ShouldRender"; 2 | import Skeleton, { SkeletonProps } from "./Skeleton"; 3 | 4 | type Props = SkeletonProps & { 5 | children?: string; 6 | className?: string; 7 | loading?: boolean; 8 | style?: React.CSSProperties; 9 | }; 10 | 11 | /** 12 | * This component receives HTML and renders it on the page. 13 | * 14 | * It is intended to be used to render a post or a comment's body, 15 | * which is parsed from markdown into HTML in the server. (tRPC router) 16 | */ 17 | const HTMLBody: React.FC = ({ 18 | children, 19 | className, 20 | loading, 21 | style, 22 | ...props 23 | }) => { 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 |
36 | 37 | 38 | ); 39 | }; 40 | 41 | export default HTMLBody; 42 | -------------------------------------------------------------------------------- /src/components/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { AiFillDislike, AiFillLike } from "react-icons/ai"; 3 | import ShouldRender from "./ShouldRender"; 4 | 5 | type Props = { 6 | dislike?: boolean; 7 | disabled: boolean; 8 | onClick: () => void; 9 | label?: number; 10 | likedOrDislikedByMe?: boolean; 11 | vertical?: boolean; 12 | }; 13 | 14 | const LikeButton: React.FC = (props) => { 15 | const { vertical, label, likedOrDislikedByMe, disabled, onClick, dislike } = 16 | props; 17 | 18 | const buttonDescription = useMemo(() => { 19 | if (dislike && !likedOrDislikedByMe) return "Dislike this post"; 20 | 21 | if (dislike && likedOrDislikedByMe) return "Undo dislike on this post"; 22 | 23 | if (!dislike && likedOrDislikedByMe) return "Undo like on this post"; 24 | 25 | if (!dislike && !likedOrDislikedByMe) return "Like this post"; 26 | }, [likedOrDislikedByMe, dislike]); 27 | 28 | return ( 29 | 70 | ); 71 | }; 72 | 73 | export default LikeButton; 74 | -------------------------------------------------------------------------------- /src/components/LikeCount.tsx: -------------------------------------------------------------------------------- 1 | import { AiFillDislike, AiFillLike } from "react-icons/ai"; 2 | import ShouldRender from "./ShouldRender"; 3 | 4 | type Props = { 5 | dislike?: boolean; 6 | label?: number; 7 | vertical?: boolean; 8 | }; 9 | 10 | const LikeCount: React.FC = (props) => { 11 | const { label, dislike, vertical = true } = props; 12 | 13 | return ( 14 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |

{label}

28 |
29 | ); 30 | }; 31 | 32 | export default LikeCount; 33 | -------------------------------------------------------------------------------- /src/components/MetaTags.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { baseUrl } from "@utils/constants"; 3 | import Head from "next/head"; 4 | import { trpc } from "@utils/trpc"; 5 | import { useSession } from "next-auth/react"; 6 | 7 | type Props = { 8 | title?: string; 9 | description?: string; 10 | image?: string; 11 | url?: string; 12 | }; 13 | 14 | const MetaTags: React.FC = ({ 15 | title, 16 | description, 17 | url = baseUrl, 18 | image, 19 | }) => { 20 | const session = useSession(); 21 | const user = session?.data?.user; 22 | 23 | const { data: totalUnreads } = trpc.notification.totalUnreads.useQuery( 24 | undefined, 25 | { 26 | enabled: !!user?.id, 27 | } 28 | ); 29 | 30 | const formattedTitle = useMemo(() => { 31 | const defaultTitle = `${totalUnreads ? `(${totalUnreads})` : ""} T3 Blog`; 32 | 33 | if (title) { 34 | return `${defaultTitle} | ${title}`; 35 | } 36 | 37 | return defaultTitle; 38 | }, [title, totalUnreads]); 39 | 40 | const DEFAULT_DESCRIPTION = "Blog built with the T3 Stack."; 41 | const LOGO_PATH = `${baseUrl}/static/square-logo.png`; 42 | const favicon = `${baseUrl}/static/favicon.ico`; 43 | 44 | const currentDescription = description || DEFAULT_DESCRIPTION; 45 | 46 | return ( 47 | 48 | {formattedTitle} 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | }; 67 | 68 | export default MetaTags; 69 | -------------------------------------------------------------------------------- /src/components/PollView/PollOption.tsx: -------------------------------------------------------------------------------- 1 | import ShouldRender from "@components/ShouldRender"; 2 | import type { User } from "@prisma/client"; 3 | import { useEffect, useRef } from "react"; 4 | import { MdOutlineCheckBox } from "react-icons/md"; 5 | 6 | type Props = { 7 | option: { 8 | votedByMe?: boolean | undefined; 9 | id: string; 10 | title: string; 11 | color: string; 12 | postId: string; 13 | pollId: string; 14 | voters: User[]; 15 | }; 16 | disabled: boolean; 17 | alreadyVoted: boolean; 18 | onClick: () => void; 19 | percentage: string; 20 | }; 21 | 22 | const PollOption: React.FC = ({ 23 | option, 24 | disabled, 25 | onClick, 26 | alreadyVoted, 27 | percentage, 28 | }) => { 29 | const optionRef = useRef(null); 30 | 31 | useEffect(() => { 32 | setTimeout(() => { 33 | if (alreadyVoted && optionRef?.current) { 34 | optionRef.current.style.width = percentage; 35 | } 36 | }, 500); 37 | // eslint-disable-next-line react-hooks/exhaustive-deps 38 | }, [alreadyVoted]); 39 | 40 | return ( 41 |
  • 42 | 43 | 57 | 58 | 59 | 60 |
    61 |
    62 |

    63 | {option.title} 64 |

    65 | 66 |
    67 | 68 | 73 | 74 | 75 |

    {percentage}

    76 |
    77 |
    78 |
    87 |
    88 | 89 |
  • 90 | ); 91 | }; 92 | 93 | export default PollOption; 94 | -------------------------------------------------------------------------------- /src/components/Popover/Popover.tsx: -------------------------------------------------------------------------------- 1 | import { Popover as HeadlessPopover, Transition } from "@headlessui/react"; 2 | import { Placement } from "@popperjs/core"; 3 | import clsx from "clsx"; 4 | import { useState } from "react"; 5 | import { usePopper } from "react-popper"; 6 | 7 | type Props = { 8 | icon: React.ReactNode; 9 | children: React.ReactNode; 10 | placement?: Placement; 11 | rounded?: boolean; 12 | className?: string; 13 | strategy?: "fixed" | "absolute"; 14 | }; 15 | 16 | const Popover: React.FC = ({ 17 | icon, 18 | children, 19 | placement = "bottom", 20 | strategy = "absolute", 21 | className, 22 | rounded, 23 | }) => { 24 | let [referenceElement, setReferenceElement] = 25 | useState(null); 26 | let [popperElement, setPopperElement] = useState(null); 27 | let { styles, attributes } = usePopper(referenceElement, popperElement, { 28 | placement: placement, 29 | strategy: strategy, 30 | modifiers: [ 31 | { 32 | name: "offset", 33 | options: { 34 | offset: [0, 10], 35 | }, 36 | }, 37 | ], 38 | }); 39 | 40 | return ( 41 | 42 | setReferenceElement(ref)} 44 | className={clsx( 45 | "inline-flex items-center gap-x-1 text-sm font-bold leading-6 text-gray-900", 46 | rounded && "rounded-full" 47 | )} 48 | aria-label="Open popover" 49 | > 50 | {icon} 51 | 52 | 53 | 61 | setPopperElement(ref)} 63 | style={styles.popper} 64 | {...attributes.popper} 65 | className="absolute left-1/2 z-10 flex w-screen max-w-max -translate-x-1/2 px-4" 66 | > 67 |
    68 |
      74 | {children} 75 |
    76 |
    77 |
    78 |
    79 |
    80 | ); 81 | }; 82 | 83 | export default Popover; 84 | -------------------------------------------------------------------------------- /src/components/Popover/PopoverItem.tsx: -------------------------------------------------------------------------------- 1 | import ShouldRender from "@components/ShouldRender"; 2 | 3 | type ItemProps = { 4 | title: string; 5 | subtitle?: string; 6 | icon?: React.ReactNode; 7 | gap?: string; 8 | onClick?: () => void; 9 | }; 10 | 11 | const PopoverItem: React.FC = ({ 12 | title, 13 | subtitle, 14 | icon, 15 | gap, 16 | onClick, 17 | }) => { 18 | return ( 19 |
  • 23 |
    24 |
    25 |

    26 | {title} 27 |

    28 | {icon} 29 |
    30 | 31 |

    {subtitle}

    32 |
    33 |
    34 |
  • 35 | ); 36 | }; 37 | 38 | export default PopoverItem; 39 | -------------------------------------------------------------------------------- /src/components/Popover/index.ts: -------------------------------------------------------------------------------- 1 | import Popover from "./Popover"; 2 | import Item from "./PopoverItem"; 3 | 4 | const exported = { 5 | Main: Popover, 6 | Item, 7 | }; 8 | 9 | export default exported; 10 | -------------------------------------------------------------------------------- /src/components/PostCard.tsx: -------------------------------------------------------------------------------- 1 | import getUserDisplayName from "@utils/getUserDisplayName"; 2 | import type { PostFromList } from "@utils/types"; 3 | import { useContextualRouting } from "next-use-contextual-routing"; 4 | import Link from "next/link"; 5 | import LikeCount from "./LikeCount"; 6 | import LinkPreview from "./LinkPreview"; 7 | import HTMLBody from "./HTMLBody"; 8 | import ShouldRender from "./ShouldRender"; 9 | import Skeleton from "./Skeleton"; 10 | import TagList from "./TagList"; 11 | 12 | type Props = { 13 | loading?: boolean; 14 | post?: PostFromList; 15 | }; 16 | 17 | const PostCard: React.FC = ({ post, loading = false }) => { 18 | const { makeContextualHref } = useContextualRouting(); 19 | const username = getUserDisplayName(post?.user); 20 | 21 | return ( 22 |
    27 | 28 | 29 | 30 |
    31 | 32 | 33 | 34 |
    35 | 36 | 44 | 45 |

    46 | {post?.title} 47 |

    48 |
    49 | 50 | 51 | 52 | 53 | 54 | 59 | {post?.body} 60 | 61 | 62 | 63 |
    64 | 65 | 66 |

    67 | by{" "} 68 | 73 | {username} 74 | 75 |

    76 |
    77 |
    78 |
    79 | ); 80 | }; 81 | 82 | export default PostCard; 83 | -------------------------------------------------------------------------------- /src/components/PostModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from "react"; 2 | import { trpc } from "@utils/trpc"; 3 | import { useRouter } from "next/router"; 4 | import { Modal } from "./Modal"; 5 | import { PostDetails } from "./PostDetails"; 6 | import { useContextualRouting } from "next-use-contextual-routing"; 7 | 8 | /** 9 | * This modal will intercept the route change and be rendered instead of 10 | * redirecting the user to the [postId] page. 11 | */ 12 | const PostModal: React.FC = () => { 13 | /** 14 | * This ref is used to make sure the tag card's portal 15 | * will be *inside* this modal. 16 | */ 17 | const tagCardRef = useRef(null); 18 | 19 | const { returnHref } = useContextualRouting(); 20 | const router = useRouter(); 21 | const postId = router.query.postId as string; 22 | 23 | const isPostPage = router.pathname.includes("posts/[postId]"); 24 | 25 | // The modal cannot render on the post's actual page. 26 | const canOpenModal = !!postId && !isPostPage; 27 | 28 | const openState = useState(false); 29 | const [open, setOpen] = openState; 30 | 31 | const onCloseModal = () => { 32 | // the timeout ensures the modal close animation is finished before 33 | // resetting the `postId` and going back to the loading state. 34 | setTimeout(() => { 35 | router.push(returnHref, undefined, { shallow: true }); 36 | }, 300); 37 | }; 38 | 39 | const { data: post, isLoading } = trpc.posts.singlePost.useQuery( 40 | { 41 | postId, 42 | }, 43 | { 44 | enabled: canOpenModal && open, 45 | } 46 | ); 47 | 48 | // Modal will open and intercept whenever there is a 49 | // `postId` router query and it is outside a post's actual page. 50 | useEffect(() => { 51 | setOpen(canOpenModal); 52 | // eslint-disable-next-line react-hooks/exhaustive-deps 53 | }, [postId]); 54 | 55 | return ( 56 | 62 |
    63 |
    64 | 70 |
    71 |
    72 |
    73 | ); 74 | }; 75 | 76 | export default PostModal; 77 | -------------------------------------------------------------------------------- /src/components/PreviewMediaModal.tsx: -------------------------------------------------------------------------------- 1 | import { MediaType } from "./AttachmentPreview"; 2 | import { Modal } from "./Modal"; 3 | import ShouldRender from "./ShouldRender"; 4 | 5 | type Props = { 6 | openState: [boolean, React.Dispatch>]; 7 | media?: MediaType; 8 | }; 9 | 10 | const PreviewMediaModal: React.FC = ({ openState, media }) => { 11 | return ( 12 | 13 |
    14 | 15 | {/* eslint-disable-next-line @next/next/no-img-element */} 16 | {media?.name} 21 | 22 | 23 | 24 | 33 |
    34 |
    35 | ); 36 | }; 37 | 38 | export default PreviewMediaModal; 39 | -------------------------------------------------------------------------------- /src/components/RouterProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import NProgress from "nprogress"; 3 | 4 | type RouterEvent = { 5 | shallow: boolean; 6 | }; 7 | 8 | NProgress.configure({ 9 | minimum: 0.3, 10 | easing: "ease", 11 | speed: 500, 12 | showSpinner: false, 13 | }); 14 | 15 | Router.events.on( 16 | "routeChangeStart", 17 | (url: string, { shallow }: RouterEvent) => { 18 | if (!shallow) NProgress.start(); 19 | } 20 | ); 21 | Router.events.on("routeChangeComplete", () => NProgress.done()); 22 | Router.events.on("routeChangeError", () => NProgress.done()); 23 | 24 | const RouterProgressBar = () => null; 25 | 26 | export default RouterProgressBar; 27 | -------------------------------------------------------------------------------- /src/components/SearchInput.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { 3 | Dispatch, 4 | SetStateAction, 5 | useCallback, 6 | useEffect, 7 | useRef, 8 | } from "react"; 9 | import { HiSearch } from "react-icons/hi"; 10 | import debounce from "lodash.debounce"; 11 | import TextInput from "./TextInput"; 12 | 13 | type Props = { 14 | setQuery: Dispatch>; 15 | placeholder: string; 16 | onValueChange?: (value: string) => void; 17 | replace?: boolean; 18 | full?: boolean; 19 | className?: string; 20 | }; 21 | 22 | const SearchInput: React.FC = ({ 23 | setQuery, 24 | placeholder, 25 | onValueChange, 26 | replace = true, 27 | full = true, 28 | className = "rounded-full", 29 | }) => { 30 | const inputRef = useRef(null); 31 | const router = useRouter(); 32 | 33 | const onChange = useCallback( 34 | (value: string) => { 35 | setQuery(value); 36 | 37 | if (onValueChange) onValueChange(value); 38 | 39 | if (replace) { 40 | if (!value) delete router.query.q; 41 | 42 | const queryObject = { 43 | query: { 44 | // Necessary to pass previous query's, 45 | // so this component can work on any page. 46 | ...router.query, 47 | ...(value && { q: value }), 48 | }, 49 | }; 50 | 51 | router.replace(queryObject, queryObject, { shallow: true }); 52 | } 53 | }, 54 | [setQuery, router, onValueChange, replace] 55 | ); 56 | 57 | const handleChange = debounce(onChange, 500); 58 | 59 | // If there is a query parameter (?q=...) when the page 60 | // first opens, we set is as the query. 61 | useEffect(() => { 62 | if (router.isReady && !!router.query.q && !!replace) { 63 | const queryParam = router.query.q as string; 64 | 65 | setQuery(queryParam); 66 | if (inputRef?.current) inputRef.current.value = queryParam; 67 | } 68 | // eslint-disable-next-line react-hooks/exhaustive-deps 69 | }, [router.isReady]); 70 | 71 | return ( 72 | } 76 | ref={inputRef} 77 | onChange={(e) => handleChange(e.target.value)} 78 | type="text" 79 | placeholder={placeholder} 80 | required 81 | title={placeholder} 82 | full={full} 83 | className={className} 84 | /> 85 | ); 86 | }; 87 | 88 | export default SearchInput; 89 | -------------------------------------------------------------------------------- /src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import clsx from "clsx"; 4 | import { ButtonLink } from "./Button"; 5 | import Carousel from "./Carousel"; 6 | import ShouldRender from "./ShouldRender"; 7 | import Skeleton from "./Skeleton"; 8 | 9 | type Props = { 10 | children?: React.ReactNode; 11 | title?: React.ReactNode; 12 | loading?: boolean; 13 | compact?: boolean; 14 | seeMoreHref?: string; 15 | }; 16 | 17 | const Section: React.FC = ({ 18 | children, 19 | title, 20 | loading, 21 | compact, 22 | seeMoreHref, 23 | }) => { 24 | return ( 25 |
    31 |
    32 |
    33 | 34 |

    40 | {title} 41 |

    42 |
    43 | 44 | {title} 45 | 46 | 47 | 48 | 49 |
    50 | 51 | 52 | 58 | 67 | See more 68 | 69 | 70 | 71 |
    72 | 73 |
    74 | {children} 75 |
    76 |
    77 | ); 78 | }; 79 | 80 | export default Section; 81 | -------------------------------------------------------------------------------- /src/components/ShouldRender.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = { 4 | if: unknown; 5 | children: React.ReactNode; 6 | }; 7 | 8 | const ShouldRender: React.FC = ({ if: condition, children }) => ( 9 | <>{condition ? children : null} 10 | ); 11 | 12 | export default ShouldRender; 13 | -------------------------------------------------------------------------------- /src/components/Skeleton.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | 3 | export type SkeletonProps = { 4 | heading?: boolean; 5 | lines?: number; 6 | width?: string; 7 | height?: string; 8 | className?: string; 9 | parentClass?: string; 10 | margin?: string; 11 | }; 12 | 13 | const Skeleton: React.FC = ({ 14 | heading = false, 15 | lines = 1, 16 | width, 17 | height, 18 | className, 19 | parentClass, 20 | margin, 21 | }) => { 22 | return ( 23 |
    27 | {Array.from({ length: lines }).map((_, i) => ( 28 |
    38 | ))} 39 | Loading... 40 |
    41 | ); 42 | }; 43 | 44 | export default Skeleton; 45 | -------------------------------------------------------------------------------- /src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | alwaysWhite?: boolean; 6 | alwaysDark?: boolean; 7 | }; 8 | 9 | const Spinner: React.FC = ({ alwaysWhite, alwaysDark }) => { 10 | const hasDefaultColors = !alwaysWhite && !alwaysDark; 11 | 12 | return ( 13 |
    23 | Loading... 24 |
    25 | ); 26 | }; 27 | 28 | export default Spinner; 29 | -------------------------------------------------------------------------------- /src/components/Tab.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | title?: string; 3 | active: boolean; 4 | onClick: () => void; 5 | label: string; 6 | className?: string; 7 | }; 8 | 9 | const Tab: React.FC = (props) => { 10 | const { active, label, onClick, title, className = "" } = props; 11 | 12 | return ( 13 | 22 | ); 23 | }; 24 | 25 | export default Tab; 26 | -------------------------------------------------------------------------------- /src/components/Tag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import clsx from "clsx"; 3 | import { TagType } from "@utils/types"; 4 | import { TagHoverCard } from "./TagHoverCard"; 5 | 6 | interface TagProps 7 | extends Omit, "onChange"> { 8 | checked?: boolean; 9 | onChange?: (checked: boolean) => void; 10 | omitBgClass?: boolean; 11 | tag?: TagType; 12 | tagCardContainerRef?: React.RefObject; 13 | } 14 | 15 | const Tag: React.FC = (props) => { 16 | const { 17 | checked, 18 | onChange, 19 | children, 20 | onClick, 21 | className, 22 | omitBgClass, 23 | tag, 24 | tagCardContainerRef, 25 | ...rest 26 | } = props; 27 | 28 | const handleClick = (e: React.MouseEvent) => { 29 | onChange?.(!checked); 30 | onClick?.(e); 31 | }; 32 | 33 | const notCheckableTag = checked === undefined; 34 | 35 | const checkableTagClasses = `${ 36 | checked 37 | ? "bg-emerald-500 text-white" 38 | : "bg-white dark:bg-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-800" 39 | }`; 40 | 41 | const regularTagClasses = "bg-emerald-500 dark:bg-teal-900 text-white"; 42 | 43 | return ( 44 | 45 | 56 | {tag?.name} 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default Tag; 63 | -------------------------------------------------------------------------------- /src/components/TagHoverCard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import * as HoverCard from "@radix-ui/react-hover-card"; 3 | import { TagType } from "@utils/types"; 4 | import Image from "@components/Image"; 5 | import Link from "next/link"; 6 | import ShouldRender from "./ShouldRender"; 7 | 8 | type Props = { 9 | children: React.ReactNode; 10 | tag?: TagType; 11 | containerRef?: React.RefObject; 12 | }; 13 | 14 | export const TagHoverCard: React.FC = ({ 15 | children, 16 | tag, 17 | containerRef, 18 | }) => { 19 | const portalContainer = containerRef?.current || undefined; 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | 35 |
    36 | 37 | {`${tag?.name} 44 | 45 |
    46 | 47 | {`${tag?.name} 54 | 55 | 59 | {tag?.name} 60 | 61 |
    62 | {tag?.description} 63 |
    64 |
    65 |
    66 |
    67 |
    68 |
    69 | ); 70 | }; 71 | 72 | TagHoverCard.displayName = "TagHoverCard"; 73 | -------------------------------------------------------------------------------- /src/components/TagList.tsx: -------------------------------------------------------------------------------- 1 | import type { Tag as TagType } from "@prisma/client"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | import Tag from "./Tag"; 5 | 6 | type Props = { 7 | tags?: TagType[]; 8 | loading: boolean; 9 | compact?: boolean; 10 | full?: boolean; 11 | tagCardContainerRef?: React.RefObject; 12 | }; 13 | 14 | const TagList: React.FC = ({ 15 | tags, 16 | loading, 17 | compact = false, 18 | full = false, 19 | tagCardContainerRef, 20 | }) => { 21 | const loadingArray = Array.from({ length: 3 }); 22 | 23 | const loadingClasses = `${ 24 | loading ? `w-16 ${compact ? "h-7" : "h-9"} opacity-60` : "" 25 | }`; 26 | 27 | const paddings = `${compact ? "px-2 py-1" : "p-2"}`; 28 | 29 | return ( 30 |
    31 | {(loading ? loadingArray : tags)?.map((tag, i) => ( 32 | 38 | 44 | 45 | ))} 46 |
    47 | ); 48 | }; 49 | 50 | export default TagList; 51 | -------------------------------------------------------------------------------- /src/components/TagPreview.tsx: -------------------------------------------------------------------------------- 1 | import { TagType } from "@utils/types"; 2 | import Image from "@components/Image"; 3 | import ShouldRender from "./ShouldRender"; 4 | import Skeleton from "./Skeleton"; 5 | import clsx from "clsx"; 6 | import Link from "next/link"; 7 | 8 | type Props = { 9 | loading: boolean; 10 | tag?: TagType; 11 | }; 12 | 13 | const TagPreview: React.FC = ({ tag, loading }) => { 14 | return ( 15 | 16 |
    22 | {tag?.name 30 |
    31 | 32 | 33 | 34 | 35 | 36 |

    37 | {tag?.name} 38 |

    39 | 40 |

    41 | {tag?.description} 42 |

    43 |
    44 |
    45 |
    46 | 47 | ); 48 | }; 49 | 50 | export default TagPreview; 51 | -------------------------------------------------------------------------------- /src/components/TagSection.tsx: -------------------------------------------------------------------------------- 1 | import { TagWithPosts } from "@utils/types"; 2 | import CompactCard from "./CompactCard"; 3 | import Image from "./Image"; 4 | import Section from "./Section"; 5 | import ShouldRender from "./ShouldRender"; 6 | import Skeleton from "./Skeleton"; 7 | 8 | type Props = { 9 | loading: boolean; 10 | tag?: TagWithPosts; 11 | }; 12 | 13 | type TitleProps = { 14 | name?: string; 15 | description?: string; 16 | avatar?: string; 17 | loading?: boolean; 18 | }; 19 | 20 | const SectionHeader = ({ name, description, avatar, loading }: TitleProps) => { 21 | return ( 22 |
    23 | {`${name} 31 | 32 | 33 |
    34 |

    35 | {name} 36 |

    37 | 38 |

    39 | {description} 40 |

    41 |
    42 |
    43 | 44 | 45 |
    46 | 47 | 48 |
    49 |
    50 |
    51 | ); 52 | }; 53 | 54 | export const TagSection: React.FC = ({ loading, tag }) => { 55 | return ( 56 |
    65 | } 66 | seeMoreHref={`/posts/tags/${tag?.id}`} 67 | > 68 | {loading ? ( 69 | 70 | ) : ( 71 | tag?.posts?.map((post, key) => ( 72 | 78 | )) 79 | )} 80 |
    81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/UpsertTagModal/TagImagePreview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IoExpandOutline } from "react-icons/io5"; 3 | import { MdClose } from "react-icons/md"; 4 | import clsx from "clsx"; 5 | 6 | type Props = { 7 | type: "avatar" | "banner"; 8 | onClickImage: () => void; 9 | removeFile: () => void; 10 | file: string; 11 | }; 12 | 13 | const TagImagePreview: React.FC = ({ 14 | file, 15 | type, 16 | onClickImage, 17 | removeFile, 18 | }) => { 19 | const isAvatar = type === "avatar"; 20 | const displayLabel = isAvatar ? "Selected avatar" : "Selected banner"; 21 | 22 | return ( 23 |
    24 |
    25 | {/* eslint-disable-next-line @next/next/no-img-element */} 26 | {displayLabel} 35 | 42 | 56 |
    57 |
    58 | ); 59 | }; 60 | 61 | export default TagImagePreview; 62 | -------------------------------------------------------------------------------- /src/components/UpsertTagModal/index.ts: -------------------------------------------------------------------------------- 1 | import { UpsertTagModal } from "./UpsertTagModal"; 2 | 3 | export default UpsertTagModal; 4 | -------------------------------------------------------------------------------- /src/components/UserPreview.tsx: -------------------------------------------------------------------------------- 1 | import type { User } from "@utils/types"; 2 | import Image from "@components/Image"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | import ShouldRender from "./ShouldRender"; 6 | import Skeleton from "./Skeleton"; 7 | 8 | type Props = { 9 | user?: Pick; 10 | loading?: boolean; 11 | }; 12 | 13 | const UserPreview: React.FC = ({ loading, user }) => { 14 | return ( 15 | 16 |
    21 |
    22 | {user?.name 30 |
    31 |
    32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |

    {user?.name}

    40 | 41 |

    42 | {user?.bio} 43 |

    44 |
    45 |
    46 |
    47 | 48 | ); 49 | }; 50 | 51 | export default UserPreview; 52 | -------------------------------------------------------------------------------- /src/hooks/aws/useUploadTagImagesToS3.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@env"; 2 | import { trpc } from "@utils/trpc"; 3 | import { useCallback } from "react"; 4 | import { generateS3Url } from "@utils/aws/generateS3Url"; 5 | import { uploadFileToS3 } from "@utils/aws/uploadFileToS3"; 6 | 7 | type Files = { 8 | avatar?: File; 9 | banner?: File; 10 | }; 11 | 12 | /** 13 | * This helper hook will upload a tag avatar and/or banner to S3. 14 | */ 15 | export const useUploadTagImagesToS3 = () => { 16 | const { mutateAsync: createPresignedTagUrl } = 17 | trpc.attachments.createPresignedTagUrl.useMutation(); 18 | 19 | const uploadTagImages = useCallback( 20 | async (tagName: string, filesToUpload: Files) => { 21 | const files = Object.values(filesToUpload).filter( 22 | (item) => item instanceof File 23 | ); 24 | 25 | const fileUrls = await Promise.all( 26 | files?.map(async (file) => { 27 | const isAvatar = file === filesToUpload?.avatar; 28 | const type = isAvatar ? "avatar" : "background"; 29 | 30 | const { url, fields } = await createPresignedTagUrl({ 31 | tagName, 32 | type, 33 | }); 34 | 35 | await uploadFileToS3(url, fields, file as File); 36 | const generatedUrl = generateS3Url( 37 | env.NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME, 38 | `${tagName}/${type}` 39 | ); 40 | 41 | // by adding a timestamp to the url, we ensure that the image cache is invalidated. 42 | const timestamp = new Date().getTime().toString(); 43 | const stampedUrl = `${generatedUrl}?${timestamp}`; 44 | 45 | return { 46 | url: stampedUrl, 47 | type, 48 | }; 49 | }) 50 | ); 51 | 52 | const uploadedAvatar = fileUrls.find((url) => url.type === "avatar"); 53 | const uploadedBanner = fileUrls.find((url) => url.type === "background"); 54 | 55 | const filteredUrls = { 56 | avatarUrl: uploadedAvatar?.url, 57 | backgroundUrl: uploadedBanner?.url, 58 | }; 59 | 60 | return filteredUrls; 61 | }, 62 | [createPresignedTagUrl] 63 | ); 64 | 65 | return { uploadTagImages }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside/index.ts: -------------------------------------------------------------------------------- 1 | import useOnClickOutside from "./useClickOutside"; 2 | 3 | export default useOnClickOutside; 4 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside/useClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from "react"; 2 | import useEventListener from "./useEventListener"; 3 | 4 | type Handler = (event: MouseEvent) => void; 5 | 6 | // https://usehooks-ts.com/react-hook/use-on-click-outside 7 | function useOnClickOutside( 8 | ref: RefObject, 9 | handler: Handler, 10 | mouseEvent: "mousedown" | "mouseup" = "mousedown" 11 | ): void { 12 | useEventListener(mouseEvent, (event) => { 13 | const el = ref?.current; 14 | 15 | // Do nothing if clicking ref's element or descendent elements 16 | if (!el || el.contains(event.target as Node)) { 17 | return; 18 | } 19 | 20 | handler(event); 21 | }); 22 | } 23 | 24 | export default useOnClickOutside; 25 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from "react"; 2 | import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect"; 3 | 4 | // MediaQueryList Event based useEventListener interface 5 | function useEventListener( 6 | eventName: K, 7 | handler: (event: MediaQueryListEventMap[K]) => void, 8 | element: RefObject, 9 | options?: boolean | AddEventListenerOptions 10 | ): void; 11 | 12 | // Window Event based useEventListener interface 13 | function useEventListener( 14 | eventName: K, 15 | handler: (event: WindowEventMap[K]) => void, 16 | element?: undefined, 17 | options?: boolean | AddEventListenerOptions 18 | ): void; 19 | 20 | // Element Event based useEventListener interface 21 | function useEventListener< 22 | K extends keyof HTMLElementEventMap, 23 | T extends HTMLElement = HTMLDivElement 24 | >( 25 | eventName: K, 26 | handler: (event: HTMLElementEventMap[K]) => void, 27 | element: RefObject, 28 | options?: boolean | AddEventListenerOptions 29 | ): void; 30 | 31 | // Document Event based useEventListener interface 32 | function useEventListener( 33 | eventName: K, 34 | handler: (event: DocumentEventMap[K]) => void, 35 | element: RefObject, 36 | options?: boolean | AddEventListenerOptions 37 | ): void; 38 | 39 | // https://usehooks-ts.com/react-hook/use-event-listener 40 | function useEventListener< 41 | KW extends keyof WindowEventMap, 42 | KH extends keyof HTMLElementEventMap, 43 | KM extends keyof MediaQueryListEventMap, 44 | T extends HTMLElement | MediaQueryList | void = void 45 | >( 46 | eventName: KW | KH | KM, 47 | handler: ( 48 | event: 49 | | WindowEventMap[KW] 50 | | HTMLElementEventMap[KH] 51 | | MediaQueryListEventMap[KM] 52 | | Event 53 | ) => void, 54 | element?: RefObject, 55 | options?: boolean | AddEventListenerOptions 56 | ) { 57 | // Create a ref that stores handler 58 | const savedHandler = useRef(handler); 59 | 60 | useIsomorphicLayoutEffect(() => { 61 | savedHandler.current = handler; 62 | }, [handler]); 63 | 64 | useEffect(() => { 65 | // Define the listening target 66 | const targetElement: T | Window = element?.current ?? window; 67 | 68 | if (!(targetElement && targetElement.addEventListener)) return; 69 | 70 | // Create event listener that calls handler function stored in ref 71 | const listener: typeof handler = (event) => savedHandler.current(event); 72 | 73 | targetElement.addEventListener(eventName, listener, options); 74 | 75 | // Remove event listener on cleanup 76 | return () => { 77 | targetElement.removeEventListener(eventName, listener, options); 78 | }; 79 | }, [eventName, element, options]); 80 | } 81 | 82 | export default useEventListener; 83 | -------------------------------------------------------------------------------- /src/hooks/useClickOutside/useIsomorphicLayoutEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useLayoutEffect } from "react"; 2 | 3 | // https://usehooks-ts.com/react-hook/use-isomorphic-layout-effect 4 | const useIsomorphicLayoutEffect = 5 | typeof window !== "undefined" ? useLayoutEffect : useEffect; 6 | 7 | export default useIsomorphicLayoutEffect; 8 | -------------------------------------------------------------------------------- /src/hooks/useFilterContent.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useTabs } from "@hooks/useTabs"; 3 | 4 | const useFilterContent = () => { 5 | const [contentFilters] = useState({ 6 | tabs: [ 7 | { 8 | label: "Most recent", 9 | id: "newest", 10 | }, 11 | { 12 | label: "Oldest", 13 | id: "oldest", 14 | }, 15 | { 16 | label: "Most interactions", 17 | id: "liked", 18 | }, 19 | ], 20 | initialTabId: "newest", 21 | }); 22 | 23 | const tabs = useTabs(contentFilters); 24 | const { selectedTab, tabProps } = tabs; 25 | 26 | return { selectedTab, tabProps }; 27 | }; 28 | 29 | export default useFilterContent; 30 | -------------------------------------------------------------------------------- /src/hooks/useGetWindowDimensions.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | const getWindowWidth = () => { 4 | const { innerWidth: width, innerHeight: height } = window; 5 | return { width, height }; 6 | }; 7 | 8 | type Dimensions = { 9 | width?: number; 10 | height?: number; 11 | }; 12 | 13 | export default function useGetWindowDimensions() { 14 | const [windowDimensions, setWindowDimensions] = useState({ 15 | width: undefined, 16 | height: undefined, 17 | }); 18 | 19 | useEffect(() => { 20 | const handleResize = () => { 21 | setWindowDimensions(getWindowWidth()); 22 | }; 23 | 24 | window.addEventListener("resize", handleResize); 25 | 26 | handleResize(); 27 | 28 | return () => window.removeEventListener("resize", handleResize); 29 | }, []); 30 | 31 | return windowDimensions; 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function useMediaQuery(query: string): boolean { 4 | const getMatches = (query: string): boolean => { 5 | // Prevents SSR issues 6 | if (typeof window !== "undefined") { 7 | return window.matchMedia(query).matches; 8 | } 9 | return false; 10 | }; 11 | 12 | const [matches, setMatches] = useState(getMatches(query)); 13 | 14 | function handleChange() { 15 | setMatches(getMatches(query)); 16 | } 17 | 18 | useEffect(() => { 19 | const matchMedia = window.matchMedia(query); 20 | 21 | // Triggered at the first client-side load and if query changes 22 | handleChange(); 23 | 24 | // Listen matchMedia 25 | if (matchMedia.addListener) { 26 | matchMedia.addListener(handleChange); 27 | } else { 28 | matchMedia.addEventListener("change", handleChange); 29 | } 30 | 31 | return () => { 32 | if (matchMedia.removeListener) { 33 | matchMedia.removeListener(handleChange); 34 | } else { 35 | matchMedia.removeEventListener("change", handleChange); 36 | } 37 | }; 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | }, [query]); 40 | 41 | return matches; 42 | } 43 | 44 | export default useMediaQuery; 45 | -------------------------------------------------------------------------------- /src/hooks/useOnScreen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | const useOnScreen = ( 4 | ref: React.RefObject, 5 | options?: IntersectionObserverInit 6 | ) => { 7 | const [isIntersecting, setIntersecting] = useState(false); 8 | 9 | useEffect(() => { 10 | const observer = new IntersectionObserver( 11 | ([entry]) => setIntersecting(entry.isIntersecting), 12 | { threshold: 1, ...(options || {}) } 13 | ); 14 | 15 | if (ref.current) { 16 | observer.observe(ref.current); 17 | } 18 | 19 | return () => { 20 | observer.disconnect(); 21 | }; 22 | // eslint-disable-next-line react-hooks/exhaustive-deps 23 | }, [ref.current]); 24 | 25 | return isIntersecting; 26 | }; 27 | 28 | export default useOnScreen; 29 | -------------------------------------------------------------------------------- /src/hooks/useTabs.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export type TabType = { label: string; id: string }; 4 | 5 | /** 6 | * This hook helps interact with the `AnimatedTabs` component, 7 | * generating all its necessary props and returning the current active tab. 8 | */ 9 | export function useTabs({ 10 | tabs, 11 | initialTabId, 12 | onChange, 13 | }: { 14 | tabs: TabType[]; 15 | initialTabId: string; 16 | onChange?: (id: string) => void; 17 | }) { 18 | const [selectedTabIndex, setSelectedTab] = useState(() => { 19 | const indexOfInitialTab = tabs.findIndex((tab) => tab.id === initialTabId); 20 | return indexOfInitialTab === -1 ? 0 : indexOfInitialTab; 21 | }); 22 | 23 | return { 24 | tabProps: { 25 | tabs, 26 | selectedTabIndex, 27 | onChange, 28 | setSelectedTab, 29 | }, 30 | selectedTab: tabs[selectedTabIndex], 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import ShouldRender from "@components/ShouldRender"; 3 | import SlideOver from "@components/SlideOver"; 4 | import { useRouter } from "next/router"; 5 | import { GiHamburgerMenu } from "react-icons/gi"; 6 | import dynamic from "next/dynamic"; 7 | import { useSession } from "next-auth/react"; 8 | import Image from "@components/Image"; 9 | import Link from "next/link"; 10 | import SearchDropdown from "./SearchDropdown"; 11 | import { SidebarContent } from "../Sidebar/Sidebar"; 12 | 13 | const NotificationDropdown = dynamic( 14 | () => import("./NotificationDropdown/NotificationDropdown"), 15 | { 16 | ssr: false, 17 | } 18 | ); 19 | 20 | const TagsDropdown = dynamic(() => import("./TagsDropdown/TagsDropdown"), { 21 | ssr: false, 22 | }); 23 | 24 | const Header: React.FC = () => { 25 | const router = useRouter(); 26 | 27 | const { status: sessionStatus } = useSession(); 28 | 29 | const openAsideState = useState(false); 30 | const [, setOpen] = openAsideState; 31 | 32 | return ( 33 |
    34 | 35 | T3 logo 36 | 37 | 38 | 45 | 46 | 47 | 48 | 49 |
    50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 | ); 62 | }; 63 | 64 | export default Header; 65 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Header/SearchDropdown/SearchDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 3 | import { useRouter } from "next/router"; 4 | import useOnClickOutside from "@hooks/useClickOutside"; 5 | import dynamic from "next/dynamic"; 6 | import clsx from "clsx"; 7 | import SearchInput from "@components/SearchInput"; 8 | 9 | // Reduce initial JS load by importing the dropdown JS only when needed. 10 | const Dropdown = dynamic(() => import("./Dropdown"), { 11 | ssr: false, 12 | }); 13 | 14 | const SearchDropdown: React.FC = () => { 15 | const [query, setQuery] = useState(""); 16 | const [open, setOpen] = useState(false); 17 | const [animateRef] = useAutoAnimate(); 18 | const clickAwayRef = useRef(null); 19 | 20 | const router = useRouter(); 21 | 22 | const handleClickOutside = () => { 23 | setOpen(false); 24 | }; 25 | useOnClickOutside(clickAwayRef, handleClickOutside); 26 | 27 | const onValueChange = (value: string) => { 28 | setOpen(!!value); 29 | }; 30 | 31 | useEffect(() => { 32 | const handleRouteChange = ( 33 | url: string, 34 | { shallow }: { shallow: boolean } 35 | ) => { 36 | if (!shallow) setOpen(false); 37 | }; 38 | 39 | router.events.on("routeChangeStart", handleRouteChange); 40 | 41 | return () => { 42 | router.events.off("routeChangeStart", handleRouteChange); 43 | }; 44 | }, [router.events]); 45 | 46 | useEffect(() => { 47 | if (query) setOpen(true); 48 | }, [query]); 49 | 50 | useEffect(() => { 51 | const handleEsc = (event: KeyboardEvent) => { 52 | if (event.key === "Escape") { 53 | setOpen(false); 54 | } 55 | }; 56 | 57 | document.addEventListener("keydown", handleEsc); 58 | 59 | return () => { 60 | document.removeEventListener("keydown", handleEsc); 61 | }; 62 | }, []); 63 | 64 | return ( 65 |
    66 |
    67 |
    68 | 79 |
    80 | 81 |
    82 |
    83 | ); 84 | }; 85 | 86 | export default SearchDropdown; 87 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Header/SearchDropdown/index.ts: -------------------------------------------------------------------------------- 1 | import SearchDropdown from "./SearchDropdown"; 2 | 3 | export default SearchDropdown; 4 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Header/index.ts: -------------------------------------------------------------------------------- 1 | import Header from "./Header"; 2 | 3 | export default Header; 4 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "./Header"; 2 | import Sidebar from "./Sidebar"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | }; 7 | 8 | const MainLayout: React.FC = ({ children }) => { 9 | return ( 10 |
    11 | 12 |
    13 |
    14 | {children} 15 |
    16 |
    17 | ); 18 | }; 19 | 20 | export default MainLayout; 21 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Sidebar/ThemeButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { RiMoonClearFill, RiSunFill } from "react-icons/ri"; 3 | import { Transition } from "@headlessui/react"; 4 | import Button from "@components/Button"; 5 | import ShouldRender from "@components/ShouldRender"; 6 | import { useTheme } from "next-themes"; 7 | import clsx from "clsx"; 8 | 9 | const iconProps = { 10 | className: "text-white w-5 h-5 -xs:w-4 -xs:h-4", 11 | }; 12 | 13 | const icons = [ 14 | , 15 | , 16 | ]; 17 | 18 | const transitionConfig = { 19 | enter: "transform transition ease-in-out duration-200 delay-150", 20 | enterFrom: "translate-y-full opacity-0", 21 | enterTo: "translate-y-0 opacity-100", 22 | leave: "transform transition ease-in-out duration-200", 23 | leaveFrom: "translate-y-0 opacity-100", 24 | leaveTo: "translate-y-full opacity-0", 25 | }; 26 | 27 | const ThemeButton = () => { 28 | const [mounted, setMounted] = useState(false); 29 | const [hovering, setHovering] = useState(false); 30 | const onHover = (value: boolean) => () => setHovering(value); 31 | 32 | const { theme, systemTheme, setTheme } = useTheme(); 33 | 34 | const currentTheme = theme === "system" ? systemTheme : theme; 35 | const isDarkMode = currentTheme === "dark"; 36 | 37 | const toggleTheme = useCallback( 38 | () => setTheme(currentTheme === "dark" ? "light" : "dark"), 39 | [setTheme, currentTheme] 40 | ); 41 | 42 | const bgClasses = clsx( 43 | isDarkMode 44 | ? "from-blue-800 from-50% via-yellow-500 via-10% to-orange-400" 45 | : "from-orange-400 from-50% via-sky-500 via-10% to-blue-800" 46 | ); 47 | 48 | useEffect(() => { 49 | setMounted(true); 50 | }, []); 51 | 52 | return ( 53 | 54 | 75 | 76 | ); 77 | }; 78 | 79 | export default ThemeButton; 80 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/Sidebar/index.ts: -------------------------------------------------------------------------------- 1 | import Sidebar from "./Sidebar"; 2 | 3 | export default Sidebar; 4 | -------------------------------------------------------------------------------- /src/layouts/MainLayout/index.ts: -------------------------------------------------------------------------------- 1 | import MainLayout from "./MainLayout"; 2 | 3 | export default MainLayout; 4 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | const Custom404 = () => { 4 | return ( 5 |
    6 |

    7 | 404 8 |

    9 | 10 |
    11 |

    12 | Sorry, the page you are looking for could not be found. 13 |

    14 |
    15 | 16 | 20 | Click here to go back to the homepage 21 | 22 |
    23 | ); 24 | }; 25 | 26 | export default Custom404; 27 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import type { AppProps } from "next/app"; 3 | import { ToastContainer } from "react-toastify"; 4 | import RouterProgressBar from "@components/RouterProgressBar"; 5 | import MainLayout from "@layouts/MainLayout"; 6 | import { ThemeProvider } from "next-themes"; 7 | import { SessionProvider } from "next-auth/react"; 8 | import { trpc } from "@utils/trpc"; 9 | import PostModal from "@components/PostModal"; 10 | import "@styles/globals.scss"; 11 | import "react-markdown-editor-lite/lib/index.css"; 12 | import "react-toastify/dist/ReactToastify.css"; 13 | import "nprogress/nprogress.css"; 14 | import "keen-slider/keen-slider.min.css"; 15 | 16 | function App({ Component, pageProps: { session, ...pageProps } }: AppProps) { 17 | return ( 18 | 19 | 20 | 21 | T3 Blog 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | "relative bg-white dark:bg-zinc-900 text-neutral-800 dark:text-white flex p-1 min-h-15 rounded-md justify-between overflow-hidden cursor-pointer p-5 border-2 dark:border-zinc-800 :dark:fill:slate-50 mb-4" 33 | } 34 | /> 35 | 36 | 37 | ); 38 | } 39 | 40 | export default trpc.withTRPC(App); 41 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 11 | 12 | 17 | 21 | 22 | 23 |
    24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { authOptions } from "@server/utils/auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | export default NextAuth(authOptions); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/attachments/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { attachmentsRouter } from "@server/router/attachments/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(attachmentsRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/comments/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { commentRouter } from "@server/router/comment/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(commentRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/likes/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { likeRouter } from "@server/router/like/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(likeRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/notification/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { notificationRouter } from "@server/router/notification/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(notificationRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/posts/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { postRouter } from "@server/router/post/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(postRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/scraper/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { scraperRouter } from "@server/router/scraper/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(scraperRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/search/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { searchRouter } from "@server/router/search/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(searchRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/tags/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { tagRouter } from "@server/router/tag/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(tagRouter); 5 | -------------------------------------------------------------------------------- /src/pages/api/trpc/users/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { userRouter } from "@server/router/user/_router"; 2 | import { createNextApiHandler } from "@utils/createNextApiHandler"; 3 | 4 | export default createNextApiHandler(userRouter); 5 | -------------------------------------------------------------------------------- /src/pages/auth/new-user.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { GetServerSidePropsContext } from "next"; 3 | import { prisma } from "@utils/prisma"; 4 | import MetaTags from "@components/MetaTags"; 5 | import Spinner from "@components/Spinner"; 6 | import { getServerAuthSession } from "@server/utils/auth"; 7 | 8 | /** 9 | * This page is where new users logging in for the first time are 10 | * redirected to, by Next Auth. Here we will just create two welcome 11 | * notifications and redirect them back on their way. 12 | */ 13 | const NewUserPage: React.FC = () => { 14 | return ( 15 | <> 16 | 17 |
    18 | 19 |
    20 | 21 | ); 22 | }; 23 | 24 | export default NewUserPage; 25 | 26 | export async function getServerSideProps({ 27 | req, 28 | res, 29 | ...context 30 | }: GetServerSidePropsContext) { 31 | const session = await getServerAuthSession({ req, res }); 32 | 33 | const callbackUrl = context.query.callbackUrl as string; 34 | 35 | if (!session?.user) { 36 | return { redirect: { destination: "/" } }; 37 | } 38 | 39 | // Only create welcome notifications once. 40 | const userHasAlreadyBeenWelcomed = await prisma.notification.findFirst({ 41 | where: { 42 | type: "WELCOME", 43 | notifiedId: session?.user.id, 44 | }, 45 | }); 46 | 47 | if (!userHasAlreadyBeenWelcomed) { 48 | const noAvatar = !session?.user?.image; 49 | const noUsername = !session?.user?.name; 50 | 51 | const createSystemNotification = (type: string) => { 52 | return prisma.notification.create({ 53 | data: { 54 | type, 55 | notifierId: session?.user.id, 56 | notifiedId: session?.user.id, 57 | }, 58 | }); 59 | }; 60 | 61 | const welcomeNotification = createSystemNotification("WELCOME"); 62 | const firstPostNotification = createSystemNotification("FIRST-POST"); 63 | const noUsernameNotification = createSystemNotification("NO-USERNAME"); 64 | const noAvatarNotification = createSystemNotification("NO-AVATAR"); 65 | 66 | const promisesToAwait = [welcomeNotification, firstPostNotification]; 67 | 68 | // If the user does not have avatar or username, they will be alerted on first sign-in. 69 | if (noAvatar) promisesToAwait.push(noAvatarNotification); 70 | if (noUsername) promisesToAwait.push(noUsernameNotification); 71 | 72 | // fetching in parallel to reduce wait. 73 | await Promise.all(promisesToAwait); 74 | } 75 | 76 | return { redirect: { destination: callbackUrl || "/" } }; 77 | } 78 | -------------------------------------------------------------------------------- /src/pages/auth/signout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import type { GetServerSidePropsContext } from "next"; 3 | import { signOut } from "next-auth/react"; 4 | import Link from "next/link"; 5 | import { useRouter } from "next/router"; 6 | import MetaTags from "@components/MetaTags"; 7 | import Button from "@components/Button"; 8 | import { getServerAuthSession } from "@server/utils/auth"; 9 | 10 | const SignoutPage: React.FC = () => { 11 | const [isLoading, setIsLoading] = useState(false); 12 | 13 | const router = useRouter(); 14 | const callbackUrl = router.query.callbackUrl as string; 15 | 16 | const handleSignout = useCallback(async () => { 17 | setIsLoading(true); 18 | await signOut({ 19 | callbackUrl: callbackUrl || "/", 20 | }); 21 | 22 | setIsLoading(false); 23 | }, [callbackUrl]); 24 | 25 | return ( 26 | <> 27 | 28 |
    29 |
    30 |
    31 |

    32 | Are you sure you want to sign out? 33 |

    34 |

    35 | Or{" "} 36 | 40 | go back to home 41 | 42 |

    43 |
    44 | 45 |
    46 | 55 |
    56 |
    57 |
    58 | 59 | ); 60 | }; 61 | 62 | export default SignoutPage; 63 | 64 | export async function getServerSideProps({ 65 | req, 66 | res, 67 | }: GetServerSidePropsContext) { 68 | const session = await getServerAuthSession({ req, res }); 69 | 70 | // If the user is not logged in, redirect. 71 | if (!session) { 72 | return { redirect: { destination: "/" } }; 73 | } 74 | 75 | return { 76 | props: {}, 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/auth/verify-request.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { GetServerSidePropsContext } from "next"; 3 | import AuthFeedbackMessage from "@components/AuthFeedbackMessage"; 4 | import MetaTags from "@components/MetaTags"; 5 | import Link from "next/link"; 6 | import { getServerAuthSession } from "@server/utils/auth"; 7 | 8 | const VerifyEmailPage: React.FC = () => { 9 | return ( 10 | <> 11 | 12 |
    13 | 14 |
    15 |

    16 | E-mail sent to your e-mail address. 17 |

    18 | 19 |

    20 | Check your inbox for your magic link. It provides a{" "} 21 | 22 | password-less{" "} 23 | 24 | access to your account. 25 |

    26 | 27 |
    28 | 32 | Go back to home 33 | 34 |
    35 |
    36 |
    37 | 38 | ); 39 | }; 40 | 41 | export default VerifyEmailPage; 42 | 43 | export async function getServerSideProps({ 44 | req, 45 | res, 46 | }: GetServerSidePropsContext) { 47 | const session = await getServerAuthSession({ req, res }); 48 | 49 | // If the user is already logged in, redirect. 50 | if (session) { 51 | return { redirect: { destination: "/" } }; 52 | } 53 | 54 | return { 55 | props: {}, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/posts/[postId].tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { trpc } from "@utils/trpc"; 3 | import { useRouter } from "next/router"; 4 | import { 5 | GetServerSidePropsContext, 6 | InferGetServerSidePropsType, 7 | NextPage, 8 | } from "next"; 9 | import { generateSSGHelper } from "@server/ssgHepers"; 10 | import { PostDetails } from "@components/PostDetails"; 11 | 12 | const SinglePostPage: NextPage< 13 | InferGetServerSidePropsType 14 | > = (props) => { 15 | const { postId } = props; 16 | 17 | const router = useRouter(); 18 | 19 | const { data, isLoading } = trpc.posts.singlePost.useQuery( 20 | { 21 | postId, 22 | }, 23 | { 24 | onSettled(data) { 25 | // if post not found, 404 26 | if (!data?.id) router.push("/404"); 27 | }, 28 | refetchOnWindowFocus: false, 29 | } 30 | ); 31 | 32 | return ; 33 | }; 34 | 35 | export default SinglePostPage; 36 | 37 | export async function getServerSideProps( 38 | context: GetServerSidePropsContext<{ postId: string }> 39 | ) { 40 | const { req, res } = context; 41 | 42 | const ssg = await generateSSGHelper({ req, res }); 43 | const postId = context.params?.postId as string; 44 | 45 | await ssg.posts.singlePost.prefetch({ 46 | postId, 47 | }); 48 | 49 | return { 50 | props: { 51 | trpcState: ssg.dehydrate(), 52 | postId, 53 | }, 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /src/schema/attachment.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const createPresignedUrlSchema = z.object({ 4 | postId: z.string(), 5 | type: z.string(), 6 | name: z.string(), 7 | randomKey: z.string(), 8 | }); 9 | 10 | export type CreatePresignedUrlInput = z.TypeOf; 11 | 12 | export const createPresignedAvatarUrlSchema = z.object({ 13 | userId: z.string(), 14 | }); 15 | 16 | export type CreatePresignedAvatarUrlInput = z.TypeOf< 17 | typeof createPresignedAvatarUrlSchema 18 | >; 19 | 20 | export const createPresignedTagUrlSchema = z.object({ 21 | tagName: z.string(), 22 | type: z.enum(["background", "avatar"]), 23 | }); 24 | 25 | export type CreatePresignedTagUrlInput = z.TypeOf< 26 | typeof createPresignedTagUrlSchema 27 | >; 28 | 29 | export const deleteAttachmentSchema = z.object({ 30 | key: z.string(), 31 | }); 32 | 33 | export type DeleteAttachmentInput = z.TypeOf; 34 | -------------------------------------------------------------------------------- /src/schema/comment.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const createCommentSchema = z.object({ 4 | body: z.string().min(2, "Minimum comment length is 2"), 5 | postId: z.string().uuid(), 6 | parentId: z.string().optional(), 7 | }); 8 | 9 | export type CreateCommentInput = z.TypeOf; 10 | 11 | export const getCommentsSchema = z.object({ 12 | postId: z.string().uuid(), 13 | }); 14 | 15 | export type GetAllCommentsInput = z.TypeOf; 16 | 17 | export const getUserCommentsSchema = z.object({ 18 | userId: z.string(), 19 | limit: z.number(), 20 | cursor: z.string().nullish(), 21 | skip: z.number().optional(), 22 | filter: z.string().optional(), 23 | }); 24 | 25 | export type GetUserCommentsInput = z.TypeOf; 26 | 27 | export const deleteCommentSchema = z.object({ 28 | commentId: z.string(), 29 | }); 30 | 31 | export type DeleteCommentInput = z.TypeOf; 32 | 33 | export const updateCommentSchema = z.object({ 34 | body: z.string().min(2, "Minimum comment length is 2"), 35 | commentId: z.string(), 36 | }); 37 | 38 | export type UpdateCommentInput = z.TypeOf; 39 | -------------------------------------------------------------------------------- /src/schema/constants.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | /** 4 | * This schema uses the `File` instance, only available on the browser, 5 | * so this schema cannot be sent to the server. Any values being validated 6 | * by this need to be deleted before sending the payload to the server. 7 | */ 8 | export const fileSchema = z.custom( 9 | (file) => { 10 | const isFile = file instanceof File; 11 | 12 | return isFile; 13 | }, 14 | { message: "Invalid file" } 15 | ); 16 | -------------------------------------------------------------------------------- /src/schema/like.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const likePostSchema = z.object({ 4 | postId: z.string().uuid(), 5 | authorId: z.string(), 6 | dislike: z.boolean().default(false), 7 | }); 8 | 9 | export type LikePostInput = z.TypeOf; 10 | -------------------------------------------------------------------------------- /src/schema/notification.schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod"; 2 | 3 | export const getNotificationsSchema = z.object({ 4 | read: z.boolean(), 5 | limit: z.number(), 6 | cursor: z.string().nullish(), 7 | skip: z.number().optional(), 8 | }); 9 | 10 | export type GetAllInput = z.TypeOf; 11 | 12 | export const markAsReadSchema = z.object({ 13 | notificationId: z.string(), 14 | }); 15 | 16 | export type MarkAsReadInput = z.TypeOf; 17 | -------------------------------------------------------------------------------- /src/schema/scraper.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const scrapePageSchema = z.object({ 4 | url: z.string().optional(), 5 | }); 6 | 7 | export type ScrapePageInput = z.TypeOf; 8 | -------------------------------------------------------------------------------- /src/schema/search.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | const type = z.enum(["posts", "comments", "tags", "users"]); 4 | 5 | export const searchSchema = z.object({ 6 | query: z.string(), 7 | type, 8 | limit: z.number(), 9 | cursor: z.string().nullish(), 10 | skip: z.number().optional(), 11 | truncateComments: z.boolean().optional(), 12 | }); 13 | 14 | export type SearchInput = z.TypeOf; 15 | 16 | export type SearchFilterTypes = z.TypeOf; 17 | -------------------------------------------------------------------------------- /src/schema/tag.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | import { fileSchema } from "./constants"; 3 | 4 | export const singleTagSchema = z.object({ 5 | name: z.string().max(50, "Maximum of 50 characters"), 6 | id: z.string(), 7 | description: z 8 | .string() 9 | .min(3, "Minimum of 3 characters") 10 | .max(256, "Maximum of 256 characters"), 11 | avatar: z.string().nonempty("Required"), 12 | backgroundImage: z.string().nonempty("Required"), 13 | avatarFile: fileSchema.optional(), 14 | backgroundImageFile: fileSchema.optional(), 15 | }); 16 | 17 | export const getSingleTagSchema = z.object({ 18 | tagId: z.string(), 19 | }); 20 | 21 | export type GetSingleTagInput = z.TypeOf; 22 | 23 | export const tagsSchema = singleTagSchema 24 | .array() 25 | .nonempty("Post must have at least one tag") 26 | .max(5, "Maximum of 5 tags per post"); 27 | 28 | export const createTagSchema = singleTagSchema; 29 | export type CreateTagInput = z.TypeOf; 30 | 31 | export const deleteTagSchema = z.object({ 32 | id: z.string(), 33 | }); 34 | 35 | export type DeleteTagInput = z.TypeOf; 36 | 37 | export const updateTagSchema = singleTagSchema; 38 | export type UpdateTagInput = z.TypeOf; 39 | 40 | export const subscribeToTagSchema = z.object({ 41 | tagId: z.string(), 42 | }); 43 | 44 | export const getSubscribedTagsSchema = z.object({ 45 | limit: z.number(), 46 | cursor: z.string().nullish(), 47 | skip: z.number().optional(), 48 | query: z.string().optional(), 49 | }); 50 | 51 | export type GetSubscribedTagsInput = z.TypeOf; 52 | -------------------------------------------------------------------------------- /src/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import z from "zod"; 2 | 3 | export const getSingleUserSchema = z.object({ 4 | userId: z.string(), 5 | }); 6 | 7 | export type GetSingleUserInput = z.TypeOf; 8 | 9 | export const signInWithEmailSchema = z.object({ 10 | email: z.string().email(), 11 | }); 12 | 13 | export type SignInWithEmailInput = z.TypeOf; 14 | 15 | export const deleteUserSchema = z.object({ 16 | userId: z.string(), 17 | }); 18 | 19 | export type DeleteUserSchema = z.TypeOf; 20 | 21 | export const followUserSchema = deleteUserSchema; 22 | 23 | export const getFollowingFromUserSchema = z.object({ 24 | limit: z.number(), 25 | cursor: z 26 | .object({ 27 | followerId: z.string(), 28 | followingId: z.string(), 29 | }) 30 | .nullish(), 31 | skip: z.number().optional(), 32 | userId: z.string().optional(), 33 | }); 34 | 35 | export type GetFollowingInput = z.TypeOf; 36 | 37 | export const getFollowersSchema = getFollowingFromUserSchema; 38 | 39 | export const urlSchema = z 40 | .object({ 41 | icon: z.string(), 42 | title: z.string(), 43 | url: z.string().url(), 44 | publisher: z.string().optional().nullable(), 45 | }) 46 | .optional() 47 | .nullable(); 48 | 49 | export const updateUserSchema = z.object({ 50 | avatar: z 51 | .custom((file) => { 52 | const isFile = file instanceof File; 53 | 54 | return isFile; 55 | }) 56 | .optional(), 57 | image: z.string().optional(), 58 | userId: z.string(), 59 | name: z.string().max(50, "Max name length is 50").optional(), 60 | url: urlSchema, 61 | bio: z.string().max(160, "Max bio length is 160").optional(), 62 | }); 63 | 64 | export type UpdateUserInput = z.TypeOf; 65 | -------------------------------------------------------------------------------- /src/server/config/aws.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from "@aws-sdk/client-s3"; 2 | import { env } from "@env"; 3 | 4 | export const s3 = new S3Client({ 5 | region: env.NEXT_PUBLIC_AWS_REGION, 6 | credentials: { 7 | accessKeyId: env.AWS_ACCESS_KEY_ID, 8 | secretAccessKey: env.AWS_SECRET_ACCESS_KEY, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/server/router/app.router.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter } from "@server/trpc"; 2 | import { commentRouter } from "./comment/_router"; 3 | import { attachmentsRouter } from "./attachments/_router"; 4 | import { likeRouter } from "./like/_router"; 5 | import { postRouter } from "./post/_router"; 6 | import { userRouter } from "./user/_router"; 7 | import { scraperRouter } from "./scraper/_router"; 8 | import { searchRouter } from "./search/_router"; 9 | import { notificationRouter } from "./notification/_router"; 10 | import { tagRouter } from "./tag/_router"; 11 | 12 | export const appRouter = createTRPCRouter({ 13 | comments: commentRouter, 14 | attachments: attachmentsRouter, 15 | likes: likeRouter, 16 | posts: postRouter, 17 | users: userRouter, 18 | scraper: scraperRouter, 19 | search: searchRouter, 20 | notification: notificationRouter, 21 | tags: tagRouter, 22 | }); 23 | 24 | // Export only the type of a router 25 | // This prevents us from importing server code on the client. 26 | export type AppRouter = typeof appRouter; 27 | -------------------------------------------------------------------------------- /src/server/router/attachments/createPresignedAvatarUrl.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { env } from "@env"; 3 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; 4 | import { s3 } from "@server/config/aws"; 5 | import type { CreatePresignedAvatarUrlInput } from "@schema/attachment.schema"; 6 | 7 | type CratePresignedTagUrlHandlerCache = { 8 | input: CreatePresignedAvatarUrlInput; 9 | }; 10 | 11 | const maxFileSize = Number(env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE); 12 | const uploadTimeLimit = Number(env.NEXT_PUBLIC_UPLOADING_TIME_LIMIT); 13 | 14 | export const createPresignedAvatarUrlHandler = async ({ 15 | input, 16 | }: CratePresignedTagUrlHandlerCache) => { 17 | const { userId } = input; 18 | try { 19 | const { url, fields } = await createPresignedPost(s3, { 20 | Bucket: env.NEXT_PUBLIC_AWS_S3_AVATARS_BUCKET_NAME, 21 | Key: userId, 22 | Expires: uploadTimeLimit, 23 | Conditions: [ 24 | ["starts-with", "$Content-Type", "image/"], 25 | ["content-length-range", 0, maxFileSize], 26 | ], 27 | }); 28 | 29 | return { url, fields }; 30 | } catch (e) { 31 | console.log("e:", e); 32 | throw new trpc.TRPCError({ 33 | code: "BAD_REQUEST", 34 | message: "Error creating S3 presigned avatar url", 35 | }); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/server/router/attachments/createPresignedPostBodyUrl.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { v4 } from "uuid"; 3 | import { env } from "@env"; 4 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; 5 | import { s3 } from "@server/config/aws"; 6 | import type { Session } from "next-auth"; 7 | 8 | type CreatePresignedPostBodyUrlHandlerCache = { 9 | ctx: { 10 | session: Session; 11 | }; 12 | }; 13 | 14 | const maxFileSize = Number(env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE); 15 | const uploadTimeLimit = Number(env.NEXT_PUBLIC_UPLOADING_TIME_LIMIT); 16 | 17 | export const createPresignedPostBodyUrlHandler = async ({ 18 | ctx, 19 | }: CreatePresignedPostBodyUrlHandlerCache) => { 20 | const userId = ctx.session.user.id; 21 | const randomKey = v4(); 22 | 23 | const key = `${userId}-${randomKey}`; 24 | 25 | try { 26 | const { url, fields } = await createPresignedPost(s3, { 27 | Bucket: env.NEXT_PUBLIC_AWS_S3_POST_BODY_BUCKET_NAME, 28 | Key: key, 29 | Expires: uploadTimeLimit, 30 | Conditions: [ 31 | ["starts-with", "$Content-Type", "image/"], 32 | ["content-length-range", 0, maxFileSize], 33 | ], 34 | }); 35 | 36 | return { url, fields, key }; 37 | } catch (e) { 38 | console.log("e:", e); 39 | throw new trpc.TRPCError({ 40 | code: "BAD_REQUEST", 41 | message: "Error creating S3 presigned post body url", 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/server/router/attachments/createPresignedTagUrl.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { env } from "@env"; 3 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; 4 | import { s3 } from "@server/config/aws"; 5 | import type { CreatePresignedTagUrlInput } from "@schema/attachment.schema"; 6 | 7 | type CratePresignedTagUrlHandlerCache = { 8 | input: CreatePresignedTagUrlInput; 9 | }; 10 | 11 | const maxFileSize = Number(env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE); 12 | const uploadTimeLimit = Number(env.NEXT_PUBLIC_UPLOADING_TIME_LIMIT); 13 | 14 | export const createPresignedTagUrlHandler = async ({ 15 | input, 16 | }: CratePresignedTagUrlHandlerCache) => { 17 | const { tagName, type } = input; 18 | 19 | try { 20 | const { url, fields } = await createPresignedPost(s3, { 21 | Bucket: env.NEXT_PUBLIC_AWS_S3_TAG_IMAGES_BUCKET_NAME, 22 | Key: `${tagName}/${type}`, 23 | Expires: uploadTimeLimit, 24 | Conditions: [ 25 | ["starts-with", "$Content-Type", "image/"], 26 | ["content-length-range", 0, maxFileSize], 27 | ], 28 | }); 29 | 30 | return { url, fields }; 31 | } catch (e) { 32 | console.log("e:", e); 33 | throw new trpc.TRPCError({ 34 | code: "BAD_REQUEST", 35 | message: "Error creating S3 presigned tag url", 36 | }); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/server/router/attachments/createPresignedUrl.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { generateS3Url } from "@utils/aws/generateS3Url"; 3 | import { env } from "@env"; 4 | import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; 5 | import { s3 } from "@server/config/aws"; 6 | import type { PrismaClient } from "@prisma/client"; 7 | import type { CreatePresignedUrlInput } from "@schema/attachment.schema"; 8 | 9 | type CreatePresignedUrlHandlerCache = { 10 | ctx: { 11 | prisma: PrismaClient; 12 | }; 13 | input: CreatePresignedUrlInput; 14 | }; 15 | 16 | const maxFileSize = Number(env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE); 17 | const uploadTimeLimit = Number(env.NEXT_PUBLIC_UPLOADING_TIME_LIMIT); 18 | 19 | export const createPresignedUrlHandler = async ({ 20 | ctx, 21 | input, 22 | }: CreatePresignedUrlHandlerCache) => { 23 | const { postId, name, type, randomKey } = input; 24 | 25 | const attachmentKey = `${postId}/${randomKey}`; 26 | 27 | const url = generateS3Url(env.AWS_S3_ATTACHMENTS_BUCKET_NAME, attachmentKey); 28 | 29 | const attachment = await ctx.prisma.attachment.create({ 30 | data: { 31 | postId, 32 | name, 33 | type, 34 | id: attachmentKey, 35 | url, 36 | }, 37 | }); 38 | 39 | try { 40 | const { url, fields } = await createPresignedPost(s3, { 41 | Key: attachment.id, 42 | Conditions: [ 43 | ["starts-with", "$Content-Type", ""], 44 | ["content-length-range", 0, maxFileSize], 45 | ], 46 | Expires: uploadTimeLimit, 47 | Bucket: env.AWS_S3_ATTACHMENTS_BUCKET_NAME, 48 | }); 49 | 50 | return { url, fields }; 51 | } catch (e) { 52 | console.log("e:", e); 53 | throw new trpc.TRPCError({ 54 | code: "BAD_REQUEST", 55 | message: "Error creating S3 presigned url", 56 | }); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/server/router/attachments/deleteAttachment.handler.ts: -------------------------------------------------------------------------------- 1 | import { DeleteObjectCommand } from "@aws-sdk/client-s3"; 2 | import { s3 } from "@server/config/aws"; 3 | import { env } from "@env"; 4 | import { DeleteAttachmentInput } from "@schema/attachment.schema"; 5 | 6 | type DeleteAttachmentOptions = { 7 | input: DeleteAttachmentInput; 8 | }; 9 | 10 | export const deleteAttachmentHandler = async ({ 11 | input, 12 | }: DeleteAttachmentOptions) => { 13 | const deleteAttachmentCommand = new DeleteObjectCommand({ 14 | Bucket: env.AWS_S3_ATTACHMENTS_BUCKET_NAME, 15 | Key: input.key, 16 | }); 17 | 18 | await s3.send(deleteAttachmentCommand); 19 | }; 20 | -------------------------------------------------------------------------------- /src/server/router/comment/allComments.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetAllCommentsInput } from "@schema/comment.schema"; 3 | import { formatComments, formatDate, markdownToHtml } from "@server/utils"; 4 | import * as trpc from "@trpc/server"; 5 | 6 | type AllCommentsOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | }; 10 | input: GetAllCommentsInput; 11 | }; 12 | 13 | export const allCommentsHandler = async ({ 14 | ctx, 15 | input, 16 | }: AllCommentsOptions) => { 17 | const { postId } = input; 18 | 19 | try { 20 | const comments = await ctx.prisma.comment.findMany({ 21 | where: { 22 | Post: { 23 | id: postId, 24 | }, 25 | }, 26 | include: { 27 | user: true, 28 | Post: { 29 | select: { 30 | userId: true, 31 | title: true, 32 | }, 33 | }, 34 | }, 35 | orderBy: { 36 | createdAt: "desc", 37 | }, 38 | }); 39 | 40 | const withFormattedBody = await Promise.all( 41 | comments.map(async (comment) => { 42 | const formattedDate = formatDate(comment.createdAt); 43 | 44 | const formattedBody = await markdownToHtml(comment?.body || "", { 45 | removeLinksAndImages: false, 46 | truncate: false, 47 | linkifyImages: true, 48 | }); 49 | 50 | return { 51 | ...comment, 52 | body: formattedBody, 53 | createdAt: formattedDate, 54 | // By also sendind the markdown body, we avoid having to 55 | // parse html back to MD when needed. 56 | markdownBody: comment?.body, 57 | authorIsOP: comment?.Post?.userId === comment?.userId, 58 | }; 59 | }) 60 | ); 61 | 62 | type CommentType = (typeof withFormattedBody)[0]; 63 | const withChildren = formatComments(withFormattedBody); 64 | 65 | return withChildren; 66 | } catch (e) { 67 | console.log("e:", e); 68 | throw new trpc.TRPCError({ 69 | code: "BAD_REQUEST", 70 | }); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/server/router/comment/deleteComment.handler.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "next-auth"; 2 | import type { PrismaClient } from "@prisma/client"; 3 | import type { DeleteCommentInput } from "@schema/comment.schema"; 4 | import { deleteChildComments } from "@server/utils"; 5 | import * as trpc from "@trpc/server"; 6 | 7 | type DeleteCommentOptions = { 8 | ctx: { 9 | session: Session; 10 | prisma: PrismaClient; 11 | }; 12 | input: DeleteCommentInput; 13 | }; 14 | 15 | export const deleteCommentHandler = async ({ 16 | ctx, 17 | input, 18 | }: DeleteCommentOptions) => { 19 | try { 20 | const { commentId } = input; 21 | const isAdmin = ctx.session.user.isAdmin; 22 | 23 | const previousComment = await ctx.prisma.comment.findFirst({ 24 | where: { 25 | id: commentId, 26 | }, 27 | }); 28 | 29 | if (previousComment?.userId !== ctx.session.user.id && !isAdmin) { 30 | throw new trpc.TRPCError({ 31 | code: "UNAUTHORIZED", 32 | message: "Cannot delete another user's comment", 33 | }); 34 | } 35 | 36 | await deleteChildComments(commentId, ctx.prisma); 37 | 38 | return true; 39 | } catch (e) { 40 | console.log(e); 41 | 42 | throw new trpc.TRPCError({ 43 | code: "BAD_REQUEST", 44 | }); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/server/router/comment/updateComment.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { UpdateCommentInput } from "@schema/comment.schema"; 3 | import type { Session } from "next-auth"; 4 | import { isStringEmpty } from "@server/utils"; 5 | import * as trpc from "@trpc/server"; 6 | 7 | type UpdateCommentOptions = { 8 | ctx: { 9 | prisma: PrismaClient; 10 | session: Session; 11 | }; 12 | input: UpdateCommentInput; 13 | }; 14 | 15 | export const updateCommentHandler = async ({ 16 | ctx, 17 | input, 18 | }: UpdateCommentOptions) => { 19 | const isAdmin = ctx.session.user.isAdmin; 20 | 21 | if (isStringEmpty(input.body)) { 22 | throw new trpc.TRPCError({ 23 | code: "BAD_REQUEST", 24 | message: "Comment cannot be empty", 25 | }); 26 | } 27 | 28 | const previousComment = await ctx.prisma.comment.findFirst({ 29 | where: { 30 | id: input.commentId, 31 | }, 32 | }); 33 | 34 | if (previousComment?.userId !== ctx.session.user.id && !isAdmin) { 35 | throw new trpc.TRPCError({ 36 | code: "UNAUTHORIZED", 37 | message: "You can only update comments created by you.", 38 | }); 39 | } 40 | 41 | const comment = await ctx.prisma.comment.update({ 42 | where: { 43 | id: input.commentId, 44 | }, 45 | data: { 46 | ...(input.body && { 47 | body: input.body, 48 | }), 49 | }, 50 | }); 51 | 52 | return comment; 53 | }; 54 | -------------------------------------------------------------------------------- /src/server/router/comment/userComments.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import { formatDate, getFiltersByInput, markdownToHtml } from "@server/utils"; 3 | import type { PrismaClient } from "@prisma/client"; 4 | import type { GetUserCommentsInput } from "@schema/comment.schema"; 5 | 6 | type UserCommentsOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | }; 10 | input: GetUserCommentsInput; 11 | }; 12 | 13 | export const userCommentsHandler = async ({ 14 | ctx, 15 | input, 16 | }: UserCommentsOptions) => { 17 | const { userId, limit, skip, cursor } = input; 18 | 19 | try { 20 | const comments = await ctx.prisma.comment.findMany({ 21 | take: limit + 1, 22 | skip: skip, 23 | cursor: cursor ? { id: cursor } : undefined, 24 | where: { 25 | userId, 26 | }, 27 | include: { 28 | user: true, 29 | Post: { 30 | select: { 31 | userId: true, 32 | title: true, 33 | }, 34 | }, 35 | }, 36 | ...(input?.filter 37 | ? { orderBy: getFiltersByInput(input?.filter, true) } 38 | : { 39 | orderBy: { 40 | createdAt: "desc", 41 | }, 42 | }), 43 | }); 44 | 45 | let nextCursor: typeof cursor | undefined = undefined; 46 | if (comments.length > limit) { 47 | const nextItem = comments.pop(); // return the last item from the array 48 | nextCursor = nextItem?.id; 49 | } 50 | 51 | const withFormattedBody = await Promise.all( 52 | comments.map(async (comment) => { 53 | const formattedDate = formatDate(comment.createdAt); 54 | 55 | const formattedBody = await markdownToHtml(comment?.body || "", { 56 | removeLinksAndImages: false, 57 | truncate: false, 58 | linkifyImages: true, 59 | }); 60 | 61 | return { 62 | ...comment, 63 | body: formattedBody, 64 | createdAt: formattedDate, 65 | markdownBody: comment.body, 66 | authorIsOP: comment?.Post?.userId === comment?.userId, 67 | children: [], 68 | }; 69 | }) 70 | ); 71 | 72 | return { comments: withFormattedBody, nextCursor }; 73 | } catch (e) { 74 | console.log("e:", e); 75 | throw new trpc.TRPCError({ 76 | code: "BAD_REQUEST", 77 | }); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/server/router/like/_router.ts: -------------------------------------------------------------------------------- 1 | import { likePostSchema } from "@schema/like.schema"; 2 | import { createTRPCRouter, protectedProcedure } from "@server/trpc"; 3 | 4 | type LikeRouterHandlerCache = { 5 | likePost?: typeof import("./likePost.handler").likePostHandler; 6 | }; 7 | 8 | const UNSTABLE_HANDLER_CACHE: LikeRouterHandlerCache = {}; 9 | 10 | export const likeRouter = createTRPCRouter({ 11 | likePost: protectedProcedure 12 | .input(likePostSchema) 13 | .mutation(async ({ input, ctx }) => { 14 | if (!UNSTABLE_HANDLER_CACHE.likePost) { 15 | UNSTABLE_HANDLER_CACHE.likePost = ( 16 | await import("./likePost.handler") 17 | ).likePostHandler; 18 | } 19 | 20 | // Unreachable code but required for type safety 21 | if (!UNSTABLE_HANDLER_CACHE.likePost) { 22 | throw new Error("Failed to load handler"); 23 | } 24 | 25 | return UNSTABLE_HANDLER_CACHE.likePost({ input, ctx }); 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /src/server/router/notification/_router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getNotificationsSchema, 3 | markAsReadSchema, 4 | } from "@schema/notification.schema"; 5 | import { createTRPCRouter, protectedProcedure } from "@server/trpc"; 6 | 7 | type NotificationRouterHandlerCache = { 8 | getAll?: typeof import("./getAll.handler").getAllHandler; 9 | totalUnreads?: typeof import("./totalUnreads.handler").totalUnreadsHandler; 10 | markAsRead?: typeof import("./markAsRead.handler").markAsReadHandler; 11 | }; 12 | 13 | const UNSTABLE_HANDLER_CACHE: NotificationRouterHandlerCache = {}; 14 | 15 | export const notificationRouter = createTRPCRouter({ 16 | getAll: protectedProcedure 17 | .input(getNotificationsSchema) 18 | .query(async ({ ctx, input }) => { 19 | if (!UNSTABLE_HANDLER_CACHE.getAll) { 20 | UNSTABLE_HANDLER_CACHE.getAll = ( 21 | await import("./getAll.handler") 22 | ).getAllHandler; 23 | } 24 | 25 | // Unreachable code but required for type safety 26 | if (!UNSTABLE_HANDLER_CACHE.getAll) { 27 | throw new Error("Failed to load handler"); 28 | } 29 | 30 | return UNSTABLE_HANDLER_CACHE.getAll({ input, ctx }); 31 | }), 32 | 33 | totalUnreads: protectedProcedure.query(async ({ ctx }) => { 34 | if (!UNSTABLE_HANDLER_CACHE.totalUnreads) { 35 | UNSTABLE_HANDLER_CACHE.totalUnreads = ( 36 | await import("./totalUnreads.handler") 37 | ).totalUnreadsHandler; 38 | } 39 | 40 | // Unreachable code but required for type safety 41 | if (!UNSTABLE_HANDLER_CACHE.totalUnreads) { 42 | throw new Error("Failed to load handler"); 43 | } 44 | 45 | return UNSTABLE_HANDLER_CACHE.totalUnreads({ ctx }); 46 | }), 47 | markAsRead: protectedProcedure 48 | .input(markAsReadSchema) 49 | .mutation(async ({ ctx, input }) => { 50 | if (!UNSTABLE_HANDLER_CACHE.markAsRead) { 51 | UNSTABLE_HANDLER_CACHE.markAsRead = ( 52 | await import("./markAsRead.handler") 53 | ).markAsReadHandler; 54 | } 55 | 56 | // Unreachable code but required for type safety 57 | if (!UNSTABLE_HANDLER_CACHE.markAsRead) { 58 | throw new Error("Failed to load handler"); 59 | } 60 | 61 | return UNSTABLE_HANDLER_CACHE.markAsRead({ ctx, input }); 62 | }), 63 | }); 64 | -------------------------------------------------------------------------------- /src/server/router/notification/markAsRead.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { MarkAsReadInput } from "@schema/notification.schema"; 3 | 4 | type MarkAsReadOptions = { 5 | ctx: { 6 | prisma: PrismaClient; 7 | }; 8 | input: MarkAsReadInput; 9 | }; 10 | 11 | export const markAsReadHandler = async ({ ctx, input }: MarkAsReadOptions) => { 12 | return await ctx.prisma.notification.update({ 13 | where: { 14 | id: input.notificationId, 15 | }, 16 | data: { 17 | read: true, 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/server/router/notification/totalUnreads.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { Session } from "next-auth"; 3 | 4 | type TotalUnreadsOptions = { 5 | ctx: { 6 | prisma: PrismaClient; 7 | session: Session; 8 | }; 9 | }; 10 | 11 | export const totalUnreadsHandler = async ({ ctx }: TotalUnreadsOptions) => { 12 | const unreads = await ctx.prisma.notification.findMany({ 13 | orderBy: { 14 | createdAt: "desc", 15 | }, 16 | where: { 17 | notifiedId: ctx.session?.user?.id, 18 | read: false, 19 | }, 20 | }); 21 | 22 | return unreads.length; 23 | }; 24 | -------------------------------------------------------------------------------- /src/server/router/post/all.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetPostsInput } from "@schema/post.schema"; 3 | import { formatPosts, getFiltersByInput } from "@server/utils"; 4 | 5 | type AllHandlerOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | }; 9 | input: GetPostsInput; 10 | }; 11 | 12 | export const allHandler = async ({ ctx, input }: AllHandlerOptions) => { 13 | const { limit, skip, cursor, filter } = input; 14 | 15 | const posts = await ctx.prisma.post.findMany({ 16 | take: limit + 1, 17 | skip: skip, 18 | cursor: cursor ? { id: cursor } : undefined, 19 | ...(filter 20 | ? { orderBy: getFiltersByInput(filter) } 21 | : { 22 | orderBy: { 23 | createdAt: "desc", 24 | }, 25 | }), 26 | include: { 27 | user: true, 28 | likes: true, 29 | tags: true, 30 | link: true, 31 | }, 32 | ...(input.userId && { 33 | where: { 34 | userId: input.userId, 35 | }, 36 | }), 37 | ...(input.tagId && { 38 | where: { 39 | tags: { 40 | some: { 41 | id: input.tagId, 42 | }, 43 | }, 44 | ...(input.query && { 45 | body: { 46 | search: input.query, 47 | }, 48 | }), 49 | }, 50 | }), 51 | }); 52 | 53 | let nextCursor: typeof cursor | undefined = undefined; 54 | if (posts.length > limit) { 55 | const nextItem = posts.pop(); // return the last item from the array 56 | nextCursor = nextItem?.id; 57 | } 58 | 59 | const formattedPosts = await formatPosts(posts); 60 | 61 | return { 62 | posts: formattedPosts, 63 | nextCursor, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/server/router/post/byTags.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetPostsByTagsInput } from "@schema/post.schema"; 3 | import { formatPosts } from "@server/utils"; 4 | 5 | type ByTagsOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | }; 9 | input: GetPostsByTagsInput; 10 | }; 11 | 12 | export const byTagsHandler = async ({ ctx, input }: ByTagsOptions) => { 13 | const { tagLimit, cursor, skip } = input; 14 | 15 | const query = input?.query; 16 | 17 | const tags = await ctx.prisma.tag.findMany({ 18 | take: tagLimit + 1, 19 | skip: skip, 20 | cursor: cursor ? { id: cursor } : undefined, 21 | include: { 22 | _count: { 23 | select: { posts: true }, 24 | }, 25 | posts: { 26 | take: 5, 27 | include: { 28 | user: true, 29 | likes: true, 30 | tags: true, 31 | link: true, 32 | }, 33 | }, 34 | }, 35 | orderBy: { 36 | posts: { 37 | _count: "desc", 38 | }, 39 | }, 40 | ...(query && { 41 | where: { 42 | OR: [ 43 | { 44 | description: { 45 | search: query, 46 | }, 47 | }, 48 | { 49 | name: { 50 | search: query, 51 | }, 52 | }, 53 | ], 54 | }, 55 | }), 56 | }); 57 | 58 | let nextCursor: typeof cursor | undefined = undefined; 59 | if (tags.length > tagLimit) { 60 | const nextItem = tags.pop(); // return the last item from the array 61 | nextCursor = nextItem?.id; 62 | } 63 | 64 | const tagsWithPosts = await Promise.all( 65 | tags.map(async (tag) => { 66 | const posts = tag.posts; 67 | const formattedPosts = await formatPosts(posts); 68 | 69 | return { 70 | ...tag, 71 | posts: formattedPosts, 72 | }; 73 | }) 74 | ); 75 | 76 | return { 77 | tags: tagsWithPosts, 78 | nextCursor, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /src/server/router/post/deletePost.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { Session } from "next-auth"; 3 | import type { GetSinglePostInput } from "@schema/post.schema"; 4 | import { deleteChildComments } from "@server/utils"; 5 | import * as trpc from "@trpc/server"; 6 | 7 | type DeletePostOptions = { 8 | ctx: { 9 | prisma: PrismaClient; 10 | session: Session; 11 | }; 12 | input: GetSinglePostInput; 13 | }; 14 | 15 | export const deletePostHandler = async ({ ctx, input }: DeletePostOptions) => { 16 | const isAdmin = ctx.session.user.isAdmin; 17 | 18 | const post = await ctx.prisma.post.findFirst({ 19 | where: { 20 | id: input.postId, 21 | }, 22 | include: { 23 | attachments: true, 24 | Comment: true, 25 | tags: { 26 | include: { 27 | _count: { 28 | select: { 29 | posts: true, 30 | }, 31 | }, 32 | }, 33 | }, 34 | }, 35 | }); 36 | 37 | if (post?.userId !== ctx.session.user.id && !isAdmin) { 38 | throw new trpc.TRPCError({ 39 | code: "UNAUTHORIZED", 40 | message: "Cannot delete another user's post.", 41 | }); 42 | } 43 | 44 | if (post?.Comment?.length) { 45 | await Promise.all( 46 | post.Comment.map(async (comment) => { 47 | await deleteChildComments(comment.id, ctx.prisma); 48 | }) 49 | ); 50 | } 51 | 52 | await ctx.prisma.post.delete({ 53 | where: { 54 | id: input.postId, 55 | }, 56 | }); 57 | 58 | const tagsToDelete = post?.tags.filter((tag) => tag._count.posts === 1); 59 | 60 | await ctx.prisma.tag.deleteMany({ 61 | where: { 62 | name: { 63 | in: tagsToDelete?.map((tag) => tag.name), 64 | }, 65 | }, 66 | }); 67 | }; 68 | -------------------------------------------------------------------------------- /src/server/router/post/favoritePost.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { FavoritePostInput } from "@schema/post.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type FavoritePostOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session; 9 | }; 10 | input: FavoritePostInput; 11 | }; 12 | 13 | export const favoritePostHandler = async ({ 14 | ctx, 15 | input, 16 | }: FavoritePostOptions) => { 17 | const { postId, userId } = input; 18 | 19 | const userHasAlreadyFavoritedPost = 20 | await ctx.prisma.favoritesOnUsers.findUnique({ 21 | where: { 22 | userId_postId: { 23 | postId, 24 | userId, 25 | }, 26 | }, 27 | }); 28 | 29 | // User is unfavoriting post. 30 | if (!!userHasAlreadyFavoritedPost) { 31 | await ctx.prisma.favoritesOnUsers.delete({ 32 | where: { 33 | userId_postId: { 34 | postId, 35 | userId, 36 | }, 37 | }, 38 | }); 39 | } 40 | 41 | // User is favoriting post. 42 | if (!userHasAlreadyFavoritedPost) { 43 | if (input.authorId !== ctx?.session?.user?.id) { 44 | await ctx.prisma.notification.create({ 45 | data: { 46 | postId: input.postId, 47 | notifierId: ctx?.session?.user?.id, 48 | notifiedId: input.authorId, 49 | type: "FAVORITE" as const, 50 | }, 51 | }); 52 | } 53 | 54 | await ctx.prisma.favoritesOnUsers.create({ 55 | data: { 56 | postId, 57 | userId, 58 | }, 59 | }); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/server/router/post/following.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import { GetFollowingPostsInput } from "@schema/post.schema"; 3 | import { formatPosts, getFiltersByInput } from "@server/utils"; 4 | import type { Session } from "next-auth"; 5 | 6 | type FollowingOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session; 10 | }; 11 | input: GetFollowingPostsInput; 12 | }; 13 | 14 | export const followingHandler = async ({ ctx, input }: FollowingOptions) => { 15 | const posts = await ctx.prisma.post.findMany({ 16 | where: { 17 | user: { 18 | followers: { 19 | some: { 20 | followerId: ctx?.session?.user?.id, 21 | }, 22 | }, 23 | }, 24 | }, 25 | include: { 26 | likes: true, 27 | user: true, 28 | link: true, 29 | tags: true, 30 | }, 31 | take: input.limit + 1, 32 | skip: input.skip, 33 | cursor: input.cursor ? { id: input.cursor } : undefined, 34 | ...(input?.filter 35 | ? { orderBy: getFiltersByInput(input?.filter) } 36 | : { 37 | orderBy: { 38 | createdAt: "desc", 39 | }, 40 | }), 41 | }); 42 | 43 | let nextCursor: typeof input.cursor | undefined = undefined; 44 | if (posts.length > input.limit) { 45 | const nextItem = posts.pop(); // return the last item from the array 46 | nextCursor = nextItem?.id; 47 | } 48 | 49 | const formattedPosts = await formatPosts(posts); 50 | 51 | return { 52 | posts: formattedPosts, 53 | nextCursor, 54 | }; 55 | }; 56 | -------------------------------------------------------------------------------- /src/server/router/post/getFavoritePosts.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetFavoritesInput } from "@schema/post.schema"; 3 | import { formatPosts } from "@server/utils"; 4 | 5 | type GetFavoritePostsOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | }; 9 | input: GetFavoritesInput; 10 | }; 11 | 12 | export const getFavoritePostsHandler = async ({ 13 | ctx, 14 | input, 15 | }: GetFavoritePostsOptions) => { 16 | const { userId, limit, skip, cursor } = input; 17 | const query = input?.query; 18 | 19 | const posts = await ctx.prisma.post.findMany({ 20 | take: limit + 1, 21 | skip: skip, 22 | cursor: cursor ? { id: cursor } : undefined, 23 | include: { 24 | user: true, 25 | likes: true, 26 | tags: true, 27 | link: true, 28 | }, 29 | where: { 30 | favoritedBy: { 31 | some: { 32 | userId, 33 | }, 34 | }, 35 | ...(query && { 36 | OR: [ 37 | { 38 | title: { 39 | search: query, 40 | }, 41 | }, 42 | { 43 | body: { 44 | search: query, 45 | }, 46 | }, 47 | ], 48 | }), 49 | }, 50 | }); 51 | 52 | let nextCursor: typeof cursor | undefined = undefined; 53 | if (posts.length > limit) { 54 | const nextItem = posts.pop(); // return the last item from the array 55 | nextCursor = nextItem?.id; 56 | } 57 | 58 | const formattedPosts = await formatPosts(posts); 59 | 60 | return { 61 | posts: formattedPosts, 62 | nextCursor, 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/server/router/post/getLikedPosts.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetFavoritesInput } from "@schema/post.schema"; 3 | import { formatPosts } from "@server/utils"; 4 | 5 | type GetLikedPostsOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | }; 9 | input: GetFavoritesInput; 10 | }; 11 | 12 | export const getLikedPostsHandler = async ({ 13 | ctx, 14 | input, 15 | }: GetLikedPostsOptions) => { 16 | const query = input?.query; 17 | 18 | const { userId, limit, skip, cursor } = input; 19 | const posts = await ctx.prisma.post.findMany({ 20 | take: limit + 1, 21 | skip: skip, 22 | cursor: cursor ? { id: cursor } : undefined, 23 | include: { 24 | user: true, 25 | likes: true, 26 | tags: true, 27 | link: true, 28 | }, 29 | where: { 30 | likes: { 31 | some: { 32 | dislike: false, 33 | userId, 34 | }, 35 | }, 36 | // We don't want to list the user's own posts, 37 | // as they are liked by the user automatically on creation. 38 | NOT: { 39 | userId, 40 | }, 41 | ...(query && { 42 | OR: [ 43 | { 44 | title: { 45 | search: query, 46 | }, 47 | }, 48 | { 49 | body: { 50 | search: query, 51 | }, 52 | }, 53 | ], 54 | }), 55 | }, 56 | }); 57 | 58 | let nextCursor: typeof cursor | undefined = undefined; 59 | if (posts.length > limit) { 60 | const nextItem = posts.pop(); // return the last item from the array 61 | nextCursor = nextItem?.id; 62 | } 63 | 64 | const formattedPosts = await formatPosts(posts); 65 | 66 | return { 67 | posts: formattedPosts, 68 | nextCursor, 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/server/router/post/singlePost.handler.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "next-auth"; 2 | import type { PrismaClient } from "@prisma/client"; 3 | import type { GetSinglePostInput } from "@schema/post.schema"; 4 | import { formatDate, getPostWithLikes, markdownToHtml } from "@server/utils"; 5 | 6 | type SinglePostOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session | null; 10 | }; 11 | input: GetSinglePostInput; 12 | }; 13 | 14 | export const singlePostHandler = async ({ ctx, input }: SinglePostOptions) => { 15 | const post = await ctx.prisma.post.findFirst({ 16 | where: { 17 | id: input.postId, 18 | }, 19 | include: { 20 | user: true, 21 | likes: true, 22 | tags: true, 23 | link: true, 24 | attachments: true, 25 | poll: { 26 | include: { 27 | options: { 28 | include: { 29 | voters: true, 30 | }, 31 | }, 32 | }, 33 | }, 34 | ...(ctx.session?.user?.id && { 35 | favoritedBy: { 36 | where: { 37 | userId: ctx.session.user.id, 38 | }, 39 | }, 40 | }), 41 | }, 42 | }); 43 | 44 | const postWithLikes = getPostWithLikes(post, ctx?.session); 45 | const favoritedByUser = postWithLikes.favoritedBy?.some( 46 | (favorite) => favorite.userId === ctx?.session?.user?.id 47 | ); 48 | 49 | const voters = post?.poll?.options.flatMap((option) => option.voters); 50 | 51 | const alreadyVoted = voters?.some( 52 | (voter) => voter.id === ctx?.session?.user.id 53 | ); 54 | 55 | const poll = post?.poll 56 | ? { 57 | ...post?.poll, 58 | alreadyVoted, 59 | voters: voters?.length || 0, 60 | options: post?.poll?.options.map((option) => ({ 61 | ...option, 62 | ...(option.voters.some( 63 | (voter) => voter.id === ctx?.session?.user?.id 64 | ) && { 65 | votedByMe: true, 66 | }), 67 | })), 68 | } 69 | : null; 70 | 71 | const htmlBody = await markdownToHtml(post?.body || "", { 72 | removeLinksAndImages: false, 73 | truncate: false, 74 | linkifyImages: true, 75 | }); 76 | 77 | const formattedDate = formatDate(post!.createdAt); 78 | 79 | return { 80 | ...postWithLikes, 81 | body: htmlBody, 82 | createdAt: formattedDate, 83 | // By also sendind the markdown body, we avoid having to 84 | // parse html back to MD when needed. 85 | markdownBody: post?.body, 86 | favoritedByMe: favoritedByUser, 87 | poll, 88 | }; 89 | }; 90 | -------------------------------------------------------------------------------- /src/server/router/post/subscribed.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetPostsFromSubbedTagsInput } from "@schema/post.schema"; 3 | import type { Session } from "next-auth"; 4 | import { formatPosts, getFiltersByInput } from "@server/utils"; 5 | 6 | type SubscribedOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session; 10 | }; 11 | input: GetPostsFromSubbedTagsInput; 12 | }; 13 | 14 | export const subscribedHandler = async ({ ctx, input }: SubscribedOptions) => { 15 | const posts = await ctx.prisma.post.findMany({ 16 | where: { 17 | tags: { 18 | some: { 19 | subscribers: { 20 | some: { 21 | id: ctx.session.user.id, 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | include: { 28 | likes: true, 29 | user: true, 30 | link: true, 31 | tags: true, 32 | }, 33 | take: input.limit + 1, 34 | skip: input.skip, 35 | cursor: input.cursor ? { id: input.cursor } : undefined, 36 | ...(input?.filter 37 | ? { orderBy: getFiltersByInput(input?.filter) } 38 | : { 39 | orderBy: { 40 | createdAt: "desc", 41 | }, 42 | }), 43 | }); 44 | 45 | let nextCursor: typeof input.cursor | undefined = undefined; 46 | if (posts.length > input.limit) { 47 | const nextItem = posts.pop(); // return the last item from the array 48 | nextCursor = nextItem?.id; 49 | } 50 | 51 | const formattedPosts = await formatPosts(posts); 52 | 53 | return { 54 | posts: formattedPosts, 55 | nextCursor, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/server/router/post/updatePost.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { Session } from "next-auth"; 3 | import type { UpdatePostInput } from "@schema/post.schema"; 4 | import { isStringEmpty } from "@server/utils"; 5 | import * as trpc from "@trpc/server"; 6 | 7 | type UpdatePostOptions = { 8 | ctx: { 9 | prisma: PrismaClient; 10 | session: Session; 11 | }; 12 | input: UpdatePostInput; 13 | }; 14 | 15 | export const updatePostHandler = async ({ ctx, input }: UpdatePostOptions) => { 16 | const isAdmin = ctx.session.user.isAdmin; 17 | 18 | if (!input?.title && !input.body) { 19 | throw new trpc.TRPCError({ 20 | code: "BAD_REQUEST", 21 | message: "At least one field must be updated", 22 | }); 23 | } 24 | 25 | if (isStringEmpty(input.body) || isStringEmpty(input.title)) { 26 | throw new trpc.TRPCError({ 27 | code: "BAD_REQUEST", 28 | message: "Body and title cannot be empty", 29 | }); 30 | } 31 | 32 | const previousPost = await ctx.prisma.post.findFirst({ 33 | where: { 34 | id: input.postId, 35 | }, 36 | include: { 37 | link: true, 38 | tags: { 39 | include: { 40 | _count: { 41 | select: { 42 | posts: true, 43 | }, 44 | }, 45 | }, 46 | }, 47 | }, 48 | }); 49 | 50 | if (previousPost?.userId !== ctx.session.user.id && !isAdmin) { 51 | throw new trpc.TRPCError({ 52 | code: "UNAUTHORIZED", 53 | message: "You can only update posts created by you.", 54 | }); 55 | } 56 | 57 | const previousLink = previousPost?.link?.url; 58 | const userIsDeletingLink = !input?.link?.url && !!previousLink; 59 | const userIsAddingNewLink = !!input?.link?.url && !!previousLink; 60 | const userIsCreatingLink = !!input?.link?.url && !previousLink; 61 | 62 | if (userIsDeletingLink || userIsAddingNewLink) { 63 | await ctx.prisma.link.delete({ 64 | where: { 65 | postId: input.postId, 66 | }, 67 | }); 68 | } 69 | 70 | const post = await ctx.prisma.post.update({ 71 | where: { 72 | id: input.postId, 73 | }, 74 | data: { 75 | ...(input.body && { 76 | body: input.body, 77 | }), 78 | ...(input.title && { 79 | title: input.title, 80 | }), 81 | link: { 82 | ...((userIsAddingNewLink || userIsCreatingLink) && 83 | !!input?.link?.url && { 84 | create: { 85 | image: input.link?.image, 86 | title: input.link?.title, 87 | url: input.link.url, 88 | description: input.link?.description, 89 | ...(input.link?.publisher && { 90 | publisher: input.link?.publisher, 91 | }), 92 | }, 93 | }), 94 | }, 95 | }, 96 | }); 97 | 98 | return post; 99 | }; 100 | -------------------------------------------------------------------------------- /src/server/router/post/voteOnPoll.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { Session } from "next-auth"; 3 | import type { VoteOnPollInput } from "@schema/post.schema"; 4 | import * as trpc from "@trpc/server"; 5 | 6 | type VoteOnPollOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session; 10 | }; 11 | input: VoteOnPollInput; 12 | }; 13 | 14 | export const voteOnPollHandler = async ({ ctx, input }: VoteOnPollOptions) => { 15 | const poll = await ctx.prisma.poll.findUnique({ 16 | where: { 17 | postId: input.postId, 18 | }, 19 | include: { 20 | options: { 21 | include: { 22 | voters: true, 23 | }, 24 | }, 25 | }, 26 | }); 27 | 28 | const voters = poll?.options.flatMap((option) => option.voters); 29 | 30 | if (voters?.find((voter) => voter.id === ctx.session.user.id)) { 31 | throw new trpc.TRPCError({ 32 | code: "BAD_REQUEST", 33 | message: "You have already voted on this poll.", 34 | }); 35 | } 36 | 37 | return await ctx.prisma.pollOption.update({ 38 | data: { 39 | voters: { 40 | connect: { 41 | id: ctx.session.user.id, 42 | }, 43 | }, 44 | }, 45 | where: { 46 | id: input.optionId, 47 | }, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/server/router/post/yourFeed.handler.ts: -------------------------------------------------------------------------------- 1 | import { formatPosts, getFiltersByInput } from "@server/utils"; 2 | import type { PrismaClient } from "@prisma/client"; 3 | import type { GetPostsFromSubbedTagsInput } from "@schema/post.schema"; 4 | import type { Session } from "next-auth"; 5 | 6 | type YourFeedOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session; 10 | }; 11 | input: GetPostsFromSubbedTagsInput; 12 | }; 13 | 14 | export const yourFeedHandler = async ({ ctx, input }: YourFeedOptions) => { 15 | const posts = await ctx.prisma.post.findMany({ 16 | where: { 17 | OR: [ 18 | { 19 | tags: { 20 | some: { 21 | subscribers: { 22 | some: { 23 | id: ctx.session.user.id, 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | { 30 | user: { 31 | followers: { 32 | some: { 33 | followerId: ctx?.session?.user?.id, 34 | }, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | include: { 41 | likes: true, 42 | user: true, 43 | link: true, 44 | tags: true, 45 | }, 46 | take: input.limit + 1, 47 | skip: input.skip, 48 | cursor: input.cursor ? { id: input.cursor } : undefined, 49 | ...(input?.filter 50 | ? { orderBy: getFiltersByInput(input?.filter) } 51 | : { 52 | orderBy: { 53 | createdAt: "desc", 54 | }, 55 | }), 56 | }); 57 | 58 | let nextCursor: typeof input.cursor | undefined = undefined; 59 | if (posts.length > input.limit) { 60 | const nextItem = posts.pop(); // return the last item from the array 61 | nextCursor = nextItem?.id; 62 | } 63 | 64 | const formattedPosts = await formatPosts(posts); 65 | 66 | return { 67 | posts: formattedPosts, 68 | nextCursor, 69 | }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/server/router/scraper/_router.ts: -------------------------------------------------------------------------------- 1 | import { scrapePageSchema } from "@schema/scraper.schema"; 2 | import { createTRPCRouter, protectedProcedure } from "@server/trpc"; 3 | 4 | type ScraperRouterHandlerCache = { 5 | scrapeLink?: typeof import("./scrapeLink.handler").scrapeLinkHandler; 6 | }; 7 | 8 | const UNSTABLE_HANDLER_CACHE: ScraperRouterHandlerCache = {}; 9 | 10 | export const scraperRouter = createTRPCRouter({ 11 | scrapeLink: protectedProcedure 12 | .input(scrapePageSchema) 13 | .query(async ({ input }) => { 14 | if (!UNSTABLE_HANDLER_CACHE.scrapeLink) { 15 | UNSTABLE_HANDLER_CACHE.scrapeLink = ( 16 | await import("./scrapeLink.handler") 17 | ).scrapeLinkHandler; 18 | } 19 | 20 | // Unreachable code but required for type safety 21 | if (!UNSTABLE_HANDLER_CACHE.scrapeLink) { 22 | throw new Error("Failed to load handler"); 23 | } 24 | 25 | return UNSTABLE_HANDLER_CACHE.scrapeLink({ input }); 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /src/server/router/scraper/scrapeLink.handler.ts: -------------------------------------------------------------------------------- 1 | import _metascraper from "metascraper"; 2 | import metascraperDescription from "metascraper-description"; 3 | import metascraperImage from "metascraper-image"; 4 | import metascraperPublisher from "metascraper-publisher"; 5 | import metascraperTitle from "metascraper-title"; 6 | import metascraperUrl from "metascraper-url"; 7 | import metascraperAmazon from "metascraper-amazon"; 8 | import metascraperTwitter from "metascraper-twitter"; 9 | import metascraperInstagram from "metascraper-instagram"; 10 | import metascraperFavicon from "metascraper-logo-favicon"; 11 | import * as trpc from "@trpc/server"; 12 | import axios from "axios"; 13 | import isURL from "validator/lib/isURL"; 14 | import { baseUrl } from "@utils/constants"; 15 | import { ScrapePageInput } from "@schema/scraper.schema"; 16 | 17 | type ScrapeLinkOptions = { 18 | input: ScrapePageInput; 19 | }; 20 | 21 | /** Check if a url contains a valid image by sending a HEAD request. */ 22 | async function isImgUrl(url: string) { 23 | return fetch(url, { method: "HEAD" }) 24 | .then((res) => { 25 | return res.headers.get("Content-Type")?.startsWith("image"); 26 | }) 27 | .catch(() => false); 28 | } 29 | 30 | const formatUrl = (url: string) => { 31 | return !/http(?:s)?:\/\//g.test(url) ? `https://${url?.trim()}` : url; 32 | }; 33 | 34 | export const scrapeLinkHandler = async ({ input }: ScrapeLinkOptions) => { 35 | if (!input.url) return null; 36 | 37 | if (!isURL(input.url)) { 38 | throw new trpc.TRPCError({ 39 | code: "BAD_REQUEST", 40 | message: "Invalid link", 41 | }); 42 | } 43 | 44 | const metascraper = _metascraper([ 45 | metascraperDescription(), 46 | metascraperImage(), 47 | metascraperPublisher(), 48 | metascraperFavicon(), 49 | metascraperTitle(), 50 | metascraperUrl(), 51 | metascraperAmazon(), 52 | metascraperTwitter(), 53 | metascraperInstagram(), 54 | ]); 55 | 56 | const getMetascraper = async (url: string) => { 57 | const { data } = await axios({ 58 | method: "get", 59 | url, 60 | timeout: 10000, // 10 seconds, 61 | timeoutErrorMessage: "Timed out after 10 seconds.", 62 | }); 63 | 64 | return metascraper({ url, html: data }); 65 | }; 66 | 67 | const formattedUrl = formatUrl(input.url); 68 | const metadata = await getMetascraper(formattedUrl); 69 | 70 | const isValidImage = await isImgUrl(metadata.image); 71 | 72 | // If image is invalid, as to not break the client-side layouts, we 73 | // replace the url with a default image. 74 | if (!isValidImage) metadata.image = `${baseUrl}/static/default.jpg`; 75 | 76 | return metadata; 77 | }; 78 | -------------------------------------------------------------------------------- /src/server/router/search/_router.ts: -------------------------------------------------------------------------------- 1 | import { searchSchema } from "@schema/search.schema"; 2 | import { createTRPCRouter, publicProcedure } from "@server/trpc"; 3 | 4 | type SearchRouterHandlerCache = { 5 | byType?: typeof import("./byType.handler").byTypeHandler; 6 | }; 7 | 8 | const UNSTABLE_HANDLER_CACHE: SearchRouterHandlerCache = {}; 9 | 10 | export const searchRouter = createTRPCRouter({ 11 | byType: publicProcedure.input(searchSchema).query(async ({ ctx, input }) => { 12 | if (!UNSTABLE_HANDLER_CACHE.byType) { 13 | UNSTABLE_HANDLER_CACHE.byType = ( 14 | await import("./byType.handler") 15 | ).byTypeHandler; 16 | } 17 | 18 | // Unreachable code but required for type safety 19 | if (!UNSTABLE_HANDLER_CACHE.byType) { 20 | throw new Error("Failed to load handler"); 21 | } 22 | 23 | return UNSTABLE_HANDLER_CACHE.byType({ ctx, input }); 24 | }), 25 | }); 26 | -------------------------------------------------------------------------------- /src/server/router/tag/all.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | 3 | type AllOptions = { 4 | ctx: { 5 | prisma: PrismaClient; 6 | }; 7 | }; 8 | 9 | export const allHandler = async ({ ctx }: AllOptions) => { 10 | const tags = ctx.prisma.tag.findMany(); 11 | 12 | return tags; 13 | }; 14 | -------------------------------------------------------------------------------- /src/server/router/tag/delete.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { DeleteTagInput } from "@schema/tag.schema"; 3 | import { deleteChildComments } from "@server/utils"; 4 | import type { Session } from "next-auth"; 5 | 6 | type DeleteOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session; 10 | }; 11 | input: DeleteTagInput; 12 | }; 13 | 14 | export const deleteHandler = async ({ ctx, input }: DeleteOptions) => { 15 | const postsWithOneTag = await ctx.prisma.post.findMany({ 16 | where: { 17 | tags: { 18 | every: { 19 | id: input.id, 20 | }, 21 | }, 22 | }, 23 | include: { 24 | Comment: true, 25 | attachments: true, 26 | }, 27 | }); 28 | 29 | await Promise.all( 30 | postsWithOneTag.map(async (post) => { 31 | if (post?.Comment?.length) { 32 | await Promise.all( 33 | post.Comment.map(async (comment) => { 34 | await deleteChildComments(comment.id, ctx.prisma); 35 | }) 36 | ); 37 | } 38 | 39 | await ctx.prisma.post.delete({ 40 | where: { 41 | id: post.id, 42 | }, 43 | }); 44 | }) 45 | ); 46 | 47 | await ctx.prisma.tag.delete({ 48 | where: { 49 | id: input.id, 50 | }, 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /src/server/router/tag/singleTag.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetSingleTagInput } from "@schema/tag.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type SingleTagOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session | null; 9 | }; 10 | input: GetSingleTagInput; 11 | }; 12 | 13 | export const singleTagHandler = async ({ ctx, input }: SingleTagOptions) => { 14 | const tag = await ctx.prisma.tag.findFirst({ 15 | where: { 16 | id: input.tagId, 17 | }, 18 | include: { 19 | subscribers: { 20 | where: { 21 | id: ctx?.session?.user?.id, 22 | }, 23 | }, 24 | _count: { 25 | select: { 26 | subscribers: true, 27 | }, 28 | }, 29 | }, 30 | }); 31 | 32 | const isSubscribed = !!tag?.subscribers?.length; 33 | 34 | return { 35 | ...tag, 36 | isSubscribed, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/server/router/tag/subscribe.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetSingleTagInput } from "@schema/tag.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type SubscribeOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session; 9 | }; 10 | input: GetSingleTagInput; 11 | }; 12 | 13 | export const subscribeHandler = async ({ ctx, input }: SubscribeOptions) => { 14 | const tag = await ctx.prisma.user.findFirst({ 15 | where: { 16 | id: ctx.session.user.id, 17 | }, 18 | select: { 19 | subscribedTags: { 20 | where: { 21 | id: input.tagId, 22 | }, 23 | }, 24 | }, 25 | }); 26 | 27 | const isAlreadyFollowingTag = !!tag?.subscribedTags?.length; 28 | 29 | // user is unsubscribing from tag 30 | if (isAlreadyFollowingTag) { 31 | await ctx.prisma.user.update({ 32 | where: { 33 | id: ctx.session.user.id, 34 | }, 35 | data: { 36 | subscribedTags: { 37 | disconnect: { 38 | id: input.tagId, 39 | }, 40 | }, 41 | }, 42 | }); 43 | } 44 | 45 | // user is subscribing to the tag 46 | if (!isAlreadyFollowingTag) { 47 | await ctx.prisma.user.update({ 48 | where: { 49 | id: ctx.session.user.id, 50 | }, 51 | data: { 52 | subscribedTags: { 53 | connect: { 54 | id: input.tagId, 55 | }, 56 | }, 57 | }, 58 | }); 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/server/router/tag/subscribed.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetSubscribedTagsInput } from "@schema/tag.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type SubscribedOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session; 9 | }; 10 | input: GetSubscribedTagsInput; 11 | }; 12 | 13 | export const subscribedHandler = async ({ ctx, input }: SubscribedOptions) => { 14 | const tags = await ctx.prisma.tag.findMany({ 15 | where: { 16 | subscribers: { 17 | some: { 18 | id: ctx.session.user.id, 19 | }, 20 | }, 21 | ...(input.query && { 22 | AND: { 23 | name: { 24 | contains: input.query, 25 | }, 26 | }, 27 | }), 28 | }, 29 | take: input.limit + 1, 30 | skip: input.skip, 31 | cursor: input.cursor ? { id: input.cursor } : undefined, 32 | }); 33 | 34 | let nextCursor: typeof input.cursor | undefined = undefined; 35 | if (tags.length > input.limit) { 36 | const nextItem = tags.pop(); // return the last item from the array 37 | nextCursor = nextItem?.id; 38 | } 39 | 40 | return { 41 | tags, 42 | nextCursor, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/server/router/tag/update.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { UpdateTagInput } from "@schema/tag.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type UpdateOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session; 9 | }; 10 | input: UpdateTagInput; 11 | }; 12 | 13 | export const updateHandler = async ({ ctx, input }: UpdateOptions) => { 14 | const { avatar, backgroundImage, description, name } = input; 15 | 16 | await ctx.prisma.tag.update({ 17 | data: { 18 | ...(avatar && { avatar }), 19 | ...(backgroundImage && { backgroundImage }), 20 | ...(description && { description }), 21 | ...(name && { name }), 22 | }, 23 | where: { 24 | id: input.id, 25 | }, 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/server/router/user/deleteUser.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import type { DeleteUserSchema } from "@schema/user.schema"; 3 | import type { Session } from "next-auth"; 4 | import type { PrismaClient } from "@prisma/client"; 5 | 6 | type DeleteUserOptions = { 7 | input: DeleteUserSchema; 8 | ctx: { 9 | session: Session; 10 | prisma: PrismaClient; 11 | }; 12 | }; 13 | 14 | export const deleteUserHandler = async ({ input, ctx }: DeleteUserOptions) => { 15 | const isAdmin = ctx?.session?.user?.isAdmin; 16 | 17 | if (ctx.session?.user.id !== input.userId && !isAdmin) { 18 | throw new trpc.TRPCError({ 19 | code: "UNAUTHORIZED", 20 | message: "You cannot delete another user's account.", 21 | }); 22 | } 23 | 24 | if (ctx.session?.user.id === input.userId) { 25 | const deleteUser = ctx.prisma.user.delete({ 26 | where: { 27 | id: input.userId, 28 | }, 29 | include: { 30 | accounts: true, 31 | }, 32 | }); 33 | 34 | await ctx.prisma.$transaction([deleteUser]); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/server/router/user/followUser.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { DeleteUserSchema } from "@schema/user.schema"; 3 | import type { Session } from "next-auth"; 4 | 5 | type FollowUserOptions = { 6 | ctx: { 7 | prisma: PrismaClient; 8 | session: Session; 9 | }; 10 | input: DeleteUserSchema; 11 | }; 12 | 13 | export const followUserHandler = async ({ ctx, input }: FollowUserOptions) => { 14 | const followerId = ctx.session.user.id; 15 | const followingId = input.userId; 16 | 17 | const isUserAlreadyFollowing = await ctx.prisma.follows.findUnique({ 18 | where: { 19 | followerId_followingId: { 20 | followerId, 21 | followingId, 22 | }, 23 | }, 24 | }); 25 | 26 | if (!!isUserAlreadyFollowing) { 27 | await ctx.prisma.follows.delete({ 28 | where: { 29 | followerId_followingId: { 30 | followerId, 31 | followingId, 32 | }, 33 | }, 34 | }); 35 | } 36 | 37 | if (!isUserAlreadyFollowing) { 38 | await ctx.prisma.follows.create({ 39 | data: { 40 | followerId, 41 | followingId, 42 | }, 43 | }); 44 | 45 | await ctx.prisma.notification.create({ 46 | data: { 47 | notifierId: followerId, 48 | notifiedId: followingId, 49 | type: "FOLLOW" as const, 50 | }, 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/server/router/user/getFollowers.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetFollowingInput } from "@schema/user.schema"; 3 | 4 | type GetFollowersOptions = { 5 | ctx: { 6 | prisma: PrismaClient; 7 | }; 8 | input: GetFollowingInput; 9 | }; 10 | 11 | export const getFollowersHandler = async ({ 12 | ctx, 13 | input, 14 | }: GetFollowersOptions) => { 15 | const { limit, skip, cursor } = input; 16 | 17 | const followers = await ctx.prisma.follows.findMany({ 18 | take: limit + 1, 19 | skip: skip, 20 | cursor: cursor ? { followerId_followingId: cursor } : undefined, 21 | where: { 22 | followingId: input.userId, 23 | }, 24 | include: { 25 | follower: true, 26 | }, 27 | }); 28 | 29 | let nextCursor: typeof cursor | undefined = undefined; 30 | if (followers.length > limit) { 31 | const nextItem = followers.pop(); // return the last item from the array 32 | nextCursor = { 33 | followerId: nextItem!.followerId, 34 | followingId: nextItem!.followingId, 35 | }; 36 | } 37 | 38 | return { 39 | followers, 40 | nextCursor, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/server/router/user/getFollowing.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { GetFollowingInput } from "@schema/user.schema"; 3 | 4 | type GetFollowingOptions = { 5 | ctx: { 6 | prisma: PrismaClient; 7 | }; 8 | input: GetFollowingInput; 9 | }; 10 | 11 | export const getFollowingHandler = async ({ 12 | ctx, 13 | input, 14 | }: GetFollowingOptions) => { 15 | const { limit, skip, cursor } = input; 16 | 17 | const following = await ctx.prisma.follows.findMany({ 18 | take: limit + 1, 19 | skip: skip, 20 | cursor: cursor ? { followerId_followingId: cursor } : undefined, 21 | where: { 22 | followerId: input.userId, 23 | }, 24 | include: { 25 | following: true, 26 | }, 27 | }); 28 | 29 | let nextCursor: typeof cursor | undefined = undefined; 30 | if (following.length > limit) { 31 | const nextItem = following.pop(); // return the last item from the array 32 | nextCursor = { 33 | followerId: nextItem!.followerId, 34 | followingId: nextItem!.followingId, 35 | }; 36 | } 37 | 38 | return { 39 | following, 40 | nextCursor, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/server/router/user/singleUser.handler.ts: -------------------------------------------------------------------------------- 1 | import type { PrismaClient } from "@prisma/client"; 2 | import type { Session } from "next-auth"; 3 | import type { GetSingleUserInput } from "@schema/user.schema"; 4 | import { formatDate } from "@server/utils"; 5 | 6 | type SingleUserOptions = { 7 | ctx: { 8 | prisma: PrismaClient; 9 | session: Session | null; 10 | }; 11 | input: GetSingleUserInput; 12 | }; 13 | 14 | export const singleUserHandler = async ({ ctx, input }: SingleUserOptions) => { 15 | const user = await ctx.prisma.user.findFirst({ 16 | where: { 17 | id: input.userId, 18 | }, 19 | include: { 20 | _count: { 21 | select: { followers: true, following: true }, 22 | }, 23 | url: true, 24 | }, 25 | }); 26 | 27 | const formattedDate = formatDate(user!.createdAt); 28 | 29 | if (ctx.session?.user?.id) { 30 | const followerId = ctx.session.user.id; 31 | const followingId = input.userId; 32 | 33 | const alreadyFollowing = await ctx.prisma.follows.findUnique({ 34 | where: { 35 | followerId_followingId: { 36 | followerId, 37 | followingId, 38 | }, 39 | }, 40 | }); 41 | 42 | return { 43 | ...user, 44 | createdAt: formattedDate, 45 | alreadyFollowing: !!alreadyFollowing, 46 | }; 47 | } 48 | 49 | return { 50 | ...user, 51 | createdAt: formattedDate, 52 | alreadyFollowing: false, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/server/router/user/updateProfile.handler.ts: -------------------------------------------------------------------------------- 1 | import * as trpc from "@trpc/server"; 2 | import type { UpdateUserInput } from "@schema/user.schema"; 3 | import type { Session } from "next-auth"; 4 | import type { PrismaClient } from "@prisma/client"; 5 | import { isStringEmpty } from "@server/utils"; 6 | 7 | type UpdateUserOptions = { 8 | input: UpdateUserInput; 9 | ctx: { 10 | session: Session; 11 | prisma: PrismaClient; 12 | }; 13 | }; 14 | 15 | export const updateProfileHandler = async ({ 16 | input, 17 | ctx, 18 | }: UpdateUserOptions) => { 19 | const { userId } = input; 20 | const isAdmin = ctx?.session?.user?.isAdmin; 21 | 22 | if (isStringEmpty(input.name)) { 23 | throw new trpc.TRPCError({ 24 | code: "BAD_REQUEST", 25 | message: "Name cannot be empty", 26 | }); 27 | } 28 | 29 | const userToUpdate = await ctx.prisma.user.findFirst({ 30 | where: { 31 | id: userId, 32 | }, 33 | include: { 34 | url: true, 35 | }, 36 | }); 37 | 38 | if (userToUpdate?.id !== ctx?.session?.user?.id && !isAdmin) { 39 | throw new trpc.TRPCError({ 40 | code: "UNAUTHORIZED", 41 | message: "You can only update your own profile.", 42 | }); 43 | } 44 | 45 | const previousLink = userToUpdate?.url?.url; 46 | const userIsDeletingLink = !input?.url?.url && !!previousLink; 47 | const userIsAddingNewLink = !!input?.url?.url && !!previousLink; 48 | const userIsCreatingLink = !!input?.url?.url && !previousLink; 49 | 50 | if (userIsDeletingLink || userIsAddingNewLink) { 51 | await ctx.prisma.userLink.delete({ 52 | where: { 53 | userId: userId, 54 | }, 55 | }); 56 | } 57 | 58 | const user = await ctx.prisma.user.update({ 59 | where: { 60 | id: userId, 61 | }, 62 | data: { 63 | ...(input.name && { 64 | name: input.name, 65 | }), 66 | bio: input?.bio, 67 | ...(input?.image && { 68 | image: input.image, 69 | }), 70 | 71 | url: { 72 | ...((userIsAddingNewLink || userIsCreatingLink) && 73 | !!input?.url?.url && { 74 | create: { 75 | icon: input.url?.icon, 76 | title: input.url?.title, 77 | url: input.url?.url, 78 | ...(input.url?.publisher && { 79 | publisher: input.url?.publisher, 80 | }), 81 | }, 82 | }), 83 | }, 84 | }, 85 | }); 86 | return user; 87 | }; 88 | -------------------------------------------------------------------------------- /src/server/ssgHepers.ts: -------------------------------------------------------------------------------- 1 | import SuperJSON from "superjson"; 2 | import { createServerSideHelpers } from "@trpc/react-query/server"; 3 | import { appRouter } from "./router/app.router"; 4 | import type { NextApiRequest, NextApiResponse } from "next"; 5 | import { createTRPCContext } from "@server/trpc"; 6 | import type { IncomingMessage, ServerResponse } from "http"; 7 | 8 | type RequestType = IncomingMessage & { 9 | cookies: Partial<{ 10 | [key: string]: string; 11 | }>; 12 | }; 13 | 14 | type ResponseType = ServerResponse; 15 | 16 | type Args = { 17 | req: RequestType; 18 | res: ResponseType; 19 | /** If true, will skip getting the server-side session. */ 20 | skipSession?: boolean; 21 | }; 22 | 23 | // We need the request and response to get the user session. 24 | export const generateSSGHelper = async ({ 25 | req, 26 | res, 27 | skipSession = false, 28 | }: Args) => 29 | createServerSideHelpers({ 30 | router: appRouter, 31 | ctx: await createTRPCContext( 32 | { 33 | req: req as NextApiRequest, 34 | res: res as NextApiResponse, 35 | }, 36 | skipSession 37 | ), 38 | transformer: SuperJSON, 39 | }); 40 | -------------------------------------------------------------------------------- /src/server/trpc.ts: -------------------------------------------------------------------------------- 1 | import { type CreateNextContextOptions } from "@trpc/server/adapters/next"; 2 | import type { Session } from "next-auth"; 3 | import { initTRPC, TRPCError } from "@trpc/server"; 4 | import superjson from "superjson"; 5 | import { prisma } from "@utils/prisma"; 6 | import { ZodError } from "zod"; 7 | import { getServerAuthSession } from "./utils/auth"; 8 | 9 | type CreateContextOptions = { 10 | session: Session | null; 11 | }; 12 | 13 | const createInnerTRPCContext = (opts: CreateContextOptions) => { 14 | return { 15 | session: opts.session, 16 | prisma: prisma, 17 | }; 18 | }; 19 | 20 | export const createTRPCContext = async ( 21 | opts: CreateNextContextOptions, 22 | /** If true, will skip getting the server-side session. */ 23 | skipSession?: boolean 24 | ) => { 25 | const { req, res } = opts; 26 | 27 | const session = skipSession ? null : await getServerAuthSession({ req, res }); 28 | 29 | return createInnerTRPCContext({ 30 | session, 31 | }); 32 | }; 33 | 34 | const t = initTRPC.context().create({ 35 | transformer: superjson, 36 | errorFormatter({ shape, error }) { 37 | return { 38 | ...shape, 39 | data: { 40 | ...shape.data, 41 | zodError: 42 | error.cause instanceof ZodError ? error.cause.flatten() : null, 43 | }, 44 | }; 45 | }, 46 | }); 47 | 48 | export const createTRPCRouter = t.router; 49 | export const mergeRouters = t.mergeRouters; 50 | 51 | export const publicProcedure = t.procedure; 52 | 53 | const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { 54 | if (!ctx.session || !ctx.session.user) { 55 | throw new TRPCError({ code: "UNAUTHORIZED" }); 56 | } 57 | return next({ 58 | ctx: { 59 | session: { ...ctx.session, user: ctx.session.user }, 60 | }, 61 | }); 62 | }); 63 | 64 | const enforceUserIsAdmin = t.middleware(({ ctx, next }) => { 65 | if (ctx.session?.user.isAdmin !== true) { 66 | throw new TRPCError({ code: "UNAUTHORIZED" }); 67 | } 68 | 69 | return next({ 70 | ctx: { 71 | session: { ...ctx.session, user: ctx.session.user }, 72 | }, 73 | }); 74 | }); 75 | 76 | export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); 77 | export const adminProcedure = t.procedure.use(enforceUserIsAdmin); 78 | -------------------------------------------------------------------------------- /src/server/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import type { GetServerSidePropsContext } from "next"; 2 | import { type NextAuthOptions, getServerSession } from "next-auth"; 3 | import GoogleProvider from "next-auth/providers/google"; 4 | import DiscordProvider from "next-auth/providers/discord"; 5 | import GithubProvider from "next-auth/providers/github"; 6 | import EmailProvider from "next-auth/providers/email"; 7 | import { PrismaAdapter } from "@next-auth/prisma-adapter"; 8 | import { PrismaClient } from "@prisma/client"; 9 | import { env } from "@env"; 10 | 11 | const prisma = new PrismaClient(); 12 | 13 | export const authOptions: NextAuthOptions = { 14 | adapter: PrismaAdapter(prisma), 15 | providers: [ 16 | GoogleProvider({ 17 | clientId: env.GOOGLE_CLIENT_ID, 18 | clientSecret: env.GOOGLE_CLIENT_SECRET, 19 | allowDangerousEmailAccountLinking: true, 20 | }), 21 | DiscordProvider({ 22 | clientId: env.DISCORD_CLIENT_ID, 23 | clientSecret: env.DISCORD_CLIENT_SECRET, 24 | allowDangerousEmailAccountLinking: true, 25 | }), 26 | GithubProvider({ 27 | clientId: env.GITHUB_ID, 28 | clientSecret: env.GITHUB_SECRET, 29 | allowDangerousEmailAccountLinking: true, 30 | }), 31 | EmailProvider({ 32 | server: { 33 | host: env.EMAIL_SERVER_HOST, 34 | port: Number(env.EMAIL_SERVER_PORT), 35 | auth: { 36 | user: env.MAILER_USER, 37 | pass: env.MAILER_PASSWORD, 38 | }, 39 | }, 40 | from: '"Leonardo Dias" ', 41 | }), 42 | ], 43 | theme: { 44 | colorScheme: "dark", 45 | }, 46 | pages: { 47 | signIn: "/auth/signin", 48 | signOut: "/auth/signout", 49 | verifyRequest: "/auth/verify-request", 50 | error: "/auth/error", 51 | newUser: "/auth/new-user", 52 | }, 53 | callbacks: { 54 | session: async ({ session, user }) => { 55 | session.user.id = user.id; 56 | session.user.isAdmin = user.role === "admin"; 57 | 58 | return Promise.resolve(session); 59 | }, 60 | }, 61 | }; 62 | 63 | /** 64 | * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. 65 | * 66 | * @see https://next-auth.js.org/configuration/nextjs 67 | */ 68 | export const getServerAuthSession = (ctx: { 69 | req: GetServerSidePropsContext["req"]; 70 | res: GetServerSidePropsContext["res"]; 71 | }) => { 72 | return getServerSession(ctx.req, ctx.res, authOptions); 73 | }; 74 | -------------------------------------------------------------------------------- /src/server/utils/deleteChildComments.ts: -------------------------------------------------------------------------------- 1 | import type { Prisma, PrismaClient } from "@prisma/client"; 2 | import type { DefaultArgs } from "@prisma/client/runtime/library"; 3 | 4 | export const deleteChildComments = async ( 5 | commentId: string, 6 | prisma: PrismaClient 7 | ) => { 8 | const oneLevelDownReplies = await prisma.comment.findMany({ 9 | where: { 10 | parentId: commentId, 11 | }, 12 | }); 13 | 14 | // If no replies, delete comment. 15 | if (!oneLevelDownReplies.length) { 16 | const commentToDelete = await prisma.comment.findFirst({ 17 | where: { 18 | id: commentId, 19 | }, 20 | }); 21 | 22 | if (!!commentToDelete) { 23 | await prisma.comment.delete({ 24 | where: { 25 | id: commentId, 26 | }, 27 | }); 28 | } 29 | } 30 | 31 | // If has replies, check for other replies inside the replies. 32 | if (oneLevelDownReplies.length > 0) { 33 | for (const reply of oneLevelDownReplies) { 34 | await deleteChildComments(reply.id, prisma); 35 | } 36 | 37 | // After checking all replies, delete comment. 38 | const commentToDelete = await prisma.comment.findFirst({ 39 | where: { 40 | id: commentId, 41 | }, 42 | }); 43 | 44 | if (commentToDelete) { 45 | await prisma.comment.delete({ 46 | where: { 47 | id: commentId, 48 | }, 49 | }); 50 | } 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/server/utils/formatComments.ts: -------------------------------------------------------------------------------- 1 | import type { Comment } from "@prisma/client"; 2 | 3 | type CommentType = Pick; 4 | 5 | type WithChildren = T & { 6 | children: Array>; 7 | }; 8 | 9 | /** 10 | * Format comments from the database and group them with their 11 | * children/parents before sending to the client. 12 | */ 13 | function formatComments(comments: Array) { 14 | const map = new Map(); 15 | 16 | const commentsWithChildren: WithChildren[] = comments?.map((comment) => ({ 17 | ...comment, 18 | children: [], 19 | })); 20 | 21 | const roots: Array> = commentsWithChildren?.filter( 22 | (comment) => comment.parentId === null 23 | ); 24 | 25 | commentsWithChildren?.forEach((comment, i) => { 26 | map.set(comment.id, i); 27 | }); 28 | 29 | for (let i = 0; i < comments.length; i++) { 30 | if (typeof commentsWithChildren[i]?.parentId === "string") { 31 | const parentCommentIndex = map.get(commentsWithChildren[i].parentId); 32 | 33 | commentsWithChildren[parentCommentIndex]?.children.push( 34 | commentsWithChildren[i] 35 | ); 36 | 37 | continue; 38 | } 39 | 40 | continue; 41 | } 42 | 43 | return roots; 44 | } 45 | 46 | export default formatComments; 47 | -------------------------------------------------------------------------------- /src/server/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | import { format, isToday, formatDistance } from "date-fns"; 2 | 3 | type Options = { smart: boolean }; 4 | 5 | export const formatDate = (date: Date, options?: Options) => { 6 | const dateToFormat = new Date(date); 7 | 8 | if (options?.smart === true) 9 | return format(dateToFormat, isToday(dateToFormat) ? "HH:mm" : "dd/MM/yyyy"); 10 | 11 | return formatDistance(dateToFormat, new Date(), { 12 | addSuffix: true, 13 | }); 14 | }; 15 | -------------------------------------------------------------------------------- /src/server/utils/formatPosts.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "next-auth"; 2 | import type { 3 | Attachment, 4 | FavoritesOnUsers, 5 | Like, 6 | Link, 7 | Poll, 8 | PollOption, 9 | Post, 10 | Tag, 11 | User, 12 | } from "@prisma/client"; 13 | import { markdownToHtml } from "./markdownToHtml"; 14 | 15 | type PostType = 16 | | (Post & { 17 | user: User | null; 18 | likes: Like[]; 19 | tags?: Tag[]; 20 | link?: Link | null; 21 | attachments?: Attachment[] | null; 22 | favoritedBy?: FavoritesOnUsers[] | undefined; 23 | poll?: 24 | | (Poll & { 25 | options: PollOption[]; 26 | }) 27 | | null; 28 | }) 29 | | null; 30 | 31 | // Filter post for likes and dislikes, and liked/dislikedByMe 32 | export const getPostWithLikes = (post: PostType, session?: Session | null) => { 33 | const likedByMe = 34 | post?.likes?.some( 35 | (like) => like.userId === session?.user.id && !like.dislike 36 | ) || false; 37 | 38 | const dislikedByMe = 39 | post?.likes?.some( 40 | (like) => like.userId === session?.user.id && like.dislike 41 | ) || false; 42 | 43 | const likes = post?.likes?.filter((like) => !like.dislike); 44 | const dislikes = post?.likes?.filter((like) => like.dislike); 45 | 46 | return { 47 | ...post, 48 | likedByMe, 49 | dislikedByMe, 50 | likes: likes?.length || 0, 51 | dislikes: dislikes?.length || 0, 52 | }; 53 | }; 54 | 55 | // Format array of posts converting post body from markdown to HTML. 56 | export async function formatPosts(posts: PostType[], session?: Session | null) { 57 | const postsWithLikes = posts.map((post) => getPostWithLikes(post, session)); 58 | 59 | const formattedPosts = await Promise.all( 60 | postsWithLikes.map(async (post) => { 61 | const formattedBody = await markdownToHtml(post?.body || ""); 62 | 63 | return { 64 | ...post, 65 | body: formattedBody, 66 | }; 67 | }) 68 | ); 69 | 70 | return formattedPosts; 71 | } 72 | -------------------------------------------------------------------------------- /src/server/utils/getFiltersByInput.ts: -------------------------------------------------------------------------------- 1 | export const getFiltersByInput = (filter?: string, isComments?: boolean) => { 2 | const filters: Record = { 3 | newest: { 4 | createdAt: "desc", 5 | }, 6 | oldest: { 7 | createdAt: "asc", 8 | }, 9 | ...(isComments 10 | ? { 11 | children: { 12 | _count: "desc", 13 | }, 14 | } 15 | : { 16 | liked: { 17 | likes: { 18 | _count: "desc", 19 | }, 20 | }, 21 | }), 22 | }; 23 | 24 | if (typeof filter === "string") { 25 | return filters[filter]; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/server/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteChildComments } from "./deleteChildComments"; 2 | import { getFiltersByInput } from "./getFiltersByInput"; 3 | import formatComments from "./formatComments"; 4 | import { formatDate } from "./formatDate"; 5 | import { isStringEmpty } from "./isStringEmpty"; 6 | import { markdownToHtml } from "./markdownToHtml"; 7 | import { formatPosts, getPostWithLikes } from "./formatPosts"; 8 | 9 | export { 10 | deleteChildComments, 11 | getPostWithLikes, 12 | markdownToHtml, 13 | formatPosts, 14 | formatDate, 15 | isStringEmpty, 16 | formatComments, 17 | getFiltersByInput, 18 | }; 19 | -------------------------------------------------------------------------------- /src/server/utils/isStringEmpty.ts: -------------------------------------------------------------------------------- 1 | export const isStringEmpty = (value?: string) => { 2 | return value?.trim().length === 0 || !value; 3 | }; 4 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { DefaultSession } from "next-auth"; 2 | 3 | declare module "next-auth" { 4 | /** 5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context 6 | */ 7 | interface Session { 8 | user: { 9 | isAdmin: boolean; 10 | id: string; 11 | } & DefaultSession["user"]; 12 | } 13 | 14 | interface User extends DefaultUser { 15 | role: "user" | "admin" | null; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/aws/generateS3Url.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@env"; 2 | 3 | /** 4 | * This helper function takes an S3 Bucket name and any params for the 5 | * object URL, and returns the generated URL. 6 | */ 7 | export function generateS3Url(bucketName: string, params: string) { 8 | const s3Region = env.NEXT_PUBLIC_AWS_REGION; 9 | 10 | const generatedUrl = `https://${bucketName}.s3.${s3Region}.amazonaws.com/${params}`; 11 | 12 | return generatedUrl; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/aws/uploadFileToS3.ts: -------------------------------------------------------------------------------- 1 | type Fields = Record; 2 | 3 | /** 4 | * This helper function takes the presigned url and fields provided by the AWS SDK, alongside 5 | * the file to be uploaded, and uploads it to the S3 bucket. 6 | */ 7 | export async function uploadFileToS3(url: string, fields: Fields, file: File) { 8 | const formData = new FormData(); 9 | 10 | Object.keys(fields).forEach((key) => { 11 | formData.append(key, fields[key]); 12 | }); 13 | 14 | formData.append("Content-Type", file.type); 15 | formData.append("file", file); 16 | 17 | await fetch(url, { 18 | method: "POST", 19 | body: formData, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import type { SearchFilterTypes } from "@schema/search.schema"; 2 | 3 | const environmentUrl = 4 | process.env.NEXT_PUBLIC_BYPASS_URL || process.env.NEXT_PUBLIC_VERCEL_URL; 5 | 6 | export const baseUrl = environmentUrl 7 | ? `https://${environmentUrl}` 8 | : `http://localhost:3000`; 9 | 10 | export const url = `${baseUrl}/api/trpc`; 11 | 12 | export const SEARCH_FILTERS: SearchFilterTypes[] = [ 13 | "posts", 14 | "comments", 15 | "tags", 16 | "users", 17 | ]; 18 | -------------------------------------------------------------------------------- /src/utils/convertToMB.ts: -------------------------------------------------------------------------------- 1 | const convertToMegabytes = (value: number) => { 2 | return value / (1024 * 1024); 3 | }; 4 | 5 | export default convertToMegabytes; 6 | -------------------------------------------------------------------------------- /src/utils/createNextApiHandler.ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from "@trpc/server/adapters/next"; 2 | import { createTRPCContext } from "@server/trpc"; 3 | import type { AnyRouter } from "@trpc/server"; 4 | 5 | export const createNextApiHandler = (router: AnyRouter) => 6 | trpcNext.createNextApiHandler({ 7 | router, 8 | createContext: ({ req, res }) => { 9 | return createTRPCContext({ req, res }); 10 | }, 11 | batching: { 12 | enabled: true, 13 | }, 14 | onError({ error }) { 15 | if (error.code === "INTERNAL_SERVER_ERROR") { 16 | console.error("Something went wrong", error); 17 | } else { 18 | console.log(error); 19 | } 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /src/utils/getUserDisplayName.ts: -------------------------------------------------------------------------------- 1 | type User = { 2 | email?: string | null; 3 | name?: string | null; 4 | }; 5 | 6 | /** 7 | * Get the user display name - Either their name, or their e-mail. 8 | */ 9 | const getUserDisplayName = (user?: User | null) => { 10 | if (!!user?.name) return user?.name; 11 | 12 | if (!!user?.email) return user?.email; 13 | 14 | return undefined; 15 | }; 16 | 17 | export default getUserDisplayName; 18 | -------------------------------------------------------------------------------- /src/utils/parseTagPayload.ts: -------------------------------------------------------------------------------- 1 | import type { CreateTagInput } from "@schema/tag.schema"; 2 | 3 | /** 4 | * This function is used to parse the tag payload before sending it to the server. 5 | * It will delete the payload's image files, as they have already been uploaded and 6 | * will not be used on the server. 7 | */ 8 | export function parseTagPayload(tag: CreateTagInput) { 9 | if (tag?.avatarFile) delete tag.avatarFile; 10 | if (tag?.backgroundImageFile) delete tag.backgroundImageFile; 11 | 12 | return tag; 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | // Stop prisma creating new client everytime this file is called. 4 | declare global { 5 | var prisma: PrismaClient | undefined; 6 | } 7 | export const prisma = global.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") { 10 | global.prisma = prisma; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | import type { AppRouter } from "@server/router/app.router"; 2 | import type { inferRouterOutputs } from "@trpc/server"; 3 | import type { ControllerRenderProps, FieldValues } from "react-hook-form"; 4 | 5 | type RouterOutput = inferRouterOutputs; 6 | 7 | export type CommentWithChildren = 8 | RouterOutput["comments"]["allComments"][number]; 9 | 10 | export type SinglePost = RouterOutput["posts"]["singlePost"]; 11 | 12 | export type PostFromList = RouterOutput["posts"]["all"]["posts"][number]; 13 | 14 | export type FollowingPosts = RouterOutput["posts"]["following"]; 15 | export type TagType = RouterOutput["tags"]["all"][number]; 16 | 17 | export type SingleTagType = RouterOutput["tags"]["singleTag"]; 18 | 19 | export type TagWithPosts = RouterOutput["posts"]["byTags"]["tags"][number]; 20 | 21 | export type TaggedPosts = 22 | RouterOutput["posts"]["byTags"]["tags"][number]["posts"][number]; 23 | 24 | export type User = RouterOutput["users"]["singleUser"]; 25 | export type UserLink = RouterOutput["users"]["singleUser"]["url"]; 26 | 27 | export type FollowingUser = 28 | RouterOutput["users"]["getFollowing"]["following"][number]["following"]; 29 | 30 | export type Metadata = RouterOutput["scraper"]["scrapeLink"]; 31 | export type Poll = RouterOutput["posts"]["singlePost"]["poll"]; 32 | 33 | export type Notification = 34 | RouterOutput["notification"]["getAll"]["list"][number]; 35 | 36 | // React-hook-form Controller's 'field' type 37 | export type FieldType = ControllerRenderProps; 38 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const defaultTheme = require("tailwindcss/defaultTheme"); 4 | 5 | module.exports = { 6 | darkMode: "class", 7 | content: ["./src/**/*.tsx"], 8 | theme: { 9 | screens: { 10 | xs: "425px", 11 | "2sm": "500px", 12 | sm: "640px", 13 | md: "768px", 14 | lg: "1024px", 15 | xl: "1220px", 16 | "2xl": "1536px", 17 | "-xl": { max: "1220px" }, 18 | "-2sm": { max: "500px" }, 19 | "-xs": { max: "425px" }, 20 | }, 21 | extend: { 22 | transitionProperty: { 23 | borderAndShadow: "border, box-shadow", 24 | }, 25 | transformOrigin: { 26 | "hover-card": "var(--radix-hover-card-content-transform-origin)", 27 | }, 28 | fontFamily: { 29 | inter: ["Inter", ...defaultTheme.fontFamily.sans], 30 | }, 31 | keyframes: { 32 | slideFromLeft: { 33 | "0%": { transform: "translateX(-100%)", opacity: "0" }, 34 | "100%": { transform: "translateX(0)", opacity: "100%" }, 35 | }, 36 | slideUpAndFade: { 37 | "0%": { opacity: 0, transform: "translateY(2px)" }, 38 | "100%": { opacity: 1, transform: "translateY(0)" }, 39 | }, 40 | slideRightAndFade: { 41 | "0%": { opacity: 0, transform: "translateX(-2px)" }, 42 | "100%": { opacity: 1, transform: "translateX(0)" }, 43 | }, 44 | slideDownAndFade: { 45 | "0%": { opacity: 0, transform: "translateY(-2px)" }, 46 | "100%": { opacity: 1, transform: "translateY(0)" }, 47 | }, 48 | slideLeftAndFade: { 49 | "0%": { opacity: 0, transform: "translateX(2px)" }, 50 | "100%": { opacity: 1, transform: "translateX(0)" }, 51 | }, 52 | }, 53 | animation: { 54 | slideOver: "slideFromLeft 500ms ease", 55 | popIn: "popIn 300ms forwards", 56 | slideUpAndFade: "slideUpAndFade 300ms cubic-bezier(0.16, 0, 0.13, 1)", 57 | slideDownAndFade: 58 | "slideDownAndFade 300ms cubic-bezier(0.16, 0, 0.13, 1)", 59 | slideRightAndFade: 60 | "slideRightAndFade 300ms cubic-bezier(0.16, 0, 0.13, 1)", 61 | slideLeftAndFade: 62 | "slideLeftAndFade 300ms cubic-bezier(0.16, 0, 0.13, 1)", 63 | }, 64 | boxShadow: { 65 | "3xl": "0 4px 6px rgba(0,0,0,.04)", 66 | "4xl": "0 6px 14px rgba(0,0,0,.08)", 67 | }, 68 | backgroundSize: { 69 | "size-200": "200% 200%", 70 | }, 71 | backgroundPosition: { 72 | "pos-0": "0% 0%", 73 | "pos-100": "100% 100%", 74 | }, 75 | }, 76 | }, 77 | plugins: [ 78 | require("@tailwindcss/typography"), 79 | require("tailwind-scrollbar")({ nocompatible: true }), 80 | ], 81 | }; 82 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": "." 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"], 21 | "extends": "./paths.json" 22 | } 23 | --------------------------------------------------------------------------------