├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── drizzle.config.json ├── next.config.js ├── package.json ├── patches └── @tanstack__react-query@4.29.3.patch ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── @trpc │ └── next-layout │ │ ├── client │ │ ├── createHydrateClient.tsx │ │ ├── createTrpcNextBeta.tsx │ │ └── index.tsx │ │ └── server │ │ ├── createTrpcNextLayout.tsx │ │ ├── index.ts │ │ └── local-storage.ts ├── app │ ├── (auth) │ │ ├── layout.tsx │ │ ├── sign-in │ │ │ └── page.tsx │ │ └── sign-up │ │ │ └── page.tsx │ ├── (protected) │ │ ├── (home) │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── notifications │ │ │ └── page.tsx │ │ ├── posts │ │ │ └── [postId] │ │ │ │ └── page.tsx │ │ ├── profile │ │ │ └── page.tsx │ │ ├── repos │ │ │ └── [repoId] │ │ │ │ └── page.tsx │ │ ├── search │ │ │ └── page.tsx │ │ └── users │ │ │ └── [username] │ │ │ └── page.tsx │ ├── layout.tsx │ └── loading.tsx ├── components │ ├── CommentForm.tsx │ ├── FeedContents.tsx │ ├── FollowAction.tsx │ ├── Followers.tsx │ ├── Following.tsx │ ├── Loader.tsx │ ├── PostForm.tsx │ ├── Profile.tsx │ ├── ProfileContents.tsx │ ├── SearchResults.tsx │ ├── SearchSection.tsx │ ├── Sidebar.tsx │ ├── cards │ │ ├── CommentCard.tsx │ │ ├── NotificationCard.tsx │ │ ├── PostCard.tsx │ │ └── RepoCard.tsx │ ├── heads │ │ └── TitleHead.tsx │ ├── lists │ │ ├── CommentLists.tsx │ │ ├── LikedPostLists.tsx │ │ ├── MyCommentLists.tsx │ │ ├── MyLikedPostLists.tsx │ │ ├── MyPostLists.tsx │ │ ├── MyRepoLists.tsx │ │ ├── NotificationLists.tsx │ │ ├── PostCommentLists.tsx │ │ ├── PostLists.tsx │ │ ├── RepoLists.tsx │ │ ├── RepoPostLists.tsx │ │ └── feeds │ │ │ ├── FollowingFeedLists.tsx │ │ │ ├── HotPostLists.tsx │ │ │ └── LatestPostLists.tsx │ ├── skeletons │ │ ├── AvatarSkeleton.tsx │ │ ├── CardSkeleton.tsx │ │ ├── StarSkeleton.tsx │ │ └── UserCardSkeleton.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── dialog.tsx │ │ ├── icons.tsx │ │ ├── input.tsx │ │ ├── scroll-area.tsx │ │ ├── separator.tsx │ │ ├── skeleton.tsx │ │ ├── tabs.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts ├── constants.ts ├── env.mjs ├── helpers │ ├── displayNumbers.ts │ ├── formatTimeAgo.ts │ └── repoId.ts ├── hooks │ ├── usePagination.tsx │ └── useRefetchTimer.tsx ├── lib │ ├── api │ │ ├── client.ts │ │ └── server.ts │ ├── pusher │ │ ├── client.ts │ │ ├── server.ts │ │ └── shared.ts │ └── utils.ts ├── middleware.ts ├── pages │ └── api │ │ ├── pusher │ │ └── push-notification.ts │ │ └── trpc │ │ └── [trpc].ts ├── providers │ ├── ClientProviders.tsx │ └── PostModalProvider.tsx ├── server │ ├── api │ │ ├── context.ts │ │ ├── procedures.ts │ │ ├── root.ts │ │ ├── routers │ │ │ ├── comments.ts │ │ │ ├── github.ts │ │ │ ├── like.ts │ │ │ ├── notifications.ts │ │ │ └── post.ts │ │ └── trpc.ts │ ├── caches │ │ ├── followingsCache.ts │ │ ├── oAuthCache.ts │ │ └── usernameCache.ts │ ├── db │ │ ├── index.ts │ │ ├── migrate.ts │ │ ├── migrations │ │ │ ├── 0000_lame_silver_centurion.sql │ │ │ ├── 0001_silky_patch.sql │ │ │ ├── 0002_reflective_warhawk.sql │ │ │ └── meta │ │ │ │ ├── 0000_snapshot.json │ │ │ │ ├── 0001_snapshot.json │ │ │ │ ├── 0002_snapshot.json │ │ │ │ └── _journal.json │ │ └── schema │ │ │ ├── comments.ts │ │ │ ├── likes.ts │ │ │ ├── notifications.ts │ │ │ └── posts.ts │ └── helpers │ │ ├── clerk.ts │ │ ├── drizzleQueries.ts │ │ ├── getMinuteDiff.ts │ │ ├── githubApi.ts │ │ ├── notifications.ts │ │ ├── pusher.ts │ │ └── trimGitHubData.ts ├── styles │ └── globals.css ├── types │ └── github.ts └── validationSchemas.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 2 | CLERK_SECRET_KEY= 3 | 4 | DATABASE_HOST= 5 | DATABASE_USERNAME= 6 | DATABASE_PASSWORD= 7 | 8 | # PUSHER 9 | PUSHER_ID= 10 | NEXT_PUBLIC_PUSHER_KEY= 11 | PUSHER_SECRET= 12 | NEXT_PUBLIC_PUSHER_CLUSTER= 13 | 14 | PUSHER_API_KEY= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/.pnpm/typescript@5.0.4/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Amir Fakhrullah 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Social 2 | 3 | A social media platform for GitHub users built mostly with Experimental Edge stacks. 4 | 5 | > **Warning** 6 | > This project is using Next.js App Router, which is not production-ready yet. This project is built for learning/testing these stacks 7 | 8 | ## Demo 9 | 10 | ![demo1](https://user-images.githubusercontent.com/73758525/236004677-9d196358-5fd0-47e0-902b-7ba917f2592f.png) 11 | 12 | ![demo2](https://user-images.githubusercontent.com/73758525/236004640-d78da87e-b7d5-40fe-9b63-fa1dddf67c2e.png) 13 | 14 | ![demo3](https://user-images.githubusercontent.com/73758525/236004660-99bae668-e437-4a69-91ff-dd1eb475d3a8.png) 15 | 16 | ## Features 17 | 18 | - You can follow/unfollow users, fork/star repositories and everything will sync-up with your GitHub account 19 | - You can share repositories in a post to share/promote/discuss it with your GitHub followers 20 | - Users can scroll through timeline in homepage to view the latest posts, trending posts and posts made by the users that you followed on GitHub 21 | - Notifications feature - view notifications in real-time when someone starring your repositories, share it in a post, comment or like your posts 22 | - User profile - you can view users profile, followers, following, posts, repositories, liked posts and comments 23 | 24 | ## Tech-stacks 25 | 26 | - [Next.js App Router](https://beta.nextjs.org/docs) 27 | - [Shadcn UI](https://ui.shadcn.com/) 28 | - [tRPC](https://trpc.io/) 29 | - [Clerk](https://clerk.com/) 30 | - [Drizzle Orm](https://github.com/drizzle-team/drizzle-orm) 31 | - [Neon Serverless](https://neon.tech/docs/serverless/serverless-driver#use-the-driver-over-http) 32 | 33 | ## Deployments/Hosting 34 | 35 | - Next.js: [Vercel](https://vercel.com/) 36 | - Database (PostgreSQL): [Neon](https://neon.tech/) 37 | - Real-time Notifications: [Pusher](https://pusher.com/) 38 | 39 | ## Others 40 | 41 | - [GitHub API](https://github.com/) 42 | 43 | ## Reference 44 | 45 | I would like to shoutout [ploskovytskyy](https://github.com/ploskovytskyy) for open-sourcing his [edge project](https://github.com/ploskovytskyy/next-app-router-trpc-drizzle-planetscale-edge) which helped me a lot in configuring these edge stacks 46 | 47 | ## Getting Started 48 | 49 | ### Clone or fork the repository 50 | 51 | To clone 52 | 53 | ``` 54 | git clone https://github.com/amirfakhrullah/gh-social.git 55 | ``` 56 | 57 | ### Install 58 | 59 | Copy `.env.example` to `.env` and update the credentials 60 | 61 | ``` 62 | cp .env.example .env 63 | ``` 64 | 65 | Install the dependencies (I'm using `pnpm`) 66 | 67 | ``` 68 | pnpm i 69 | ``` 70 | 71 | Run 72 | 73 | ``` 74 | pnpm dev 75 | ``` 76 | ## License 77 | 78 | License under the [MIT License](./LICENSE) 79 | -------------------------------------------------------------------------------- /drizzle.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "./src/server/db/migrations", 3 | "schema": "./src/server/db/schema" 4 | } 5 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | }, 7 | images: { 8 | domains: ["images.clerk.dev"], 9 | }, 10 | }; 11 | 12 | module.exports = nextConfig; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gh-social", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "ts-node": "ts-node -O \"{\\\"module\\\":\\\"commonjs\\\"}\"", 11 | "db:generate": "pnpm drizzle-kit generate:pg", 12 | "db:migrate": "tsx src/server/db/migrate.ts", 13 | "db:up": "pnpm drizzle-kit up:mysql" 14 | }, 15 | "dependencies": { 16 | "@clerk/nextjs": "^4.16.4", 17 | "@clerk/themes": "^1.6.3", 18 | "@hookform/resolvers": "^3.1.0", 19 | "@neondatabase/serverless": "^0.8.1", 20 | "@planetscale/database": "^1.16.0", 21 | "@radix-ui/react-alert-dialog": "^1.0.3", 22 | "@radix-ui/react-avatar": "^1.0.2", 23 | "@radix-ui/react-dialog": "^1.0.3", 24 | "@radix-ui/react-scroll-area": "^1.0.3", 25 | "@radix-ui/react-separator": "^1.0.2", 26 | "@radix-ui/react-tabs": "^1.0.3", 27 | "@radix-ui/react-toast": "^1.1.3", 28 | "@radix-ui/react-tooltip": "^1.0.5", 29 | "@tanstack/query-core": "^4.29.1", 30 | "@tanstack/react-query": "^4.29.3", 31 | "@trpc/client": "^10.21.1", 32 | "@trpc/next": "^10.21.1", 33 | "@trpc/react-query": "^10.21.1", 34 | "@trpc/server": "^10.21.1", 35 | "@types/node": "18.16.0", 36 | "@types/react": "18.0.38", 37 | "@types/react-dom": "18.0.11", 38 | "autoprefixer": "10.4.14", 39 | "class-variance-authority": "^0.5.2", 40 | "clsx": "^1.2.1", 41 | "drizzle-orm": "^0.29.3", 42 | "eslint": "8.39.0", 43 | "eslint-config-next": "13.3.1", 44 | "lucide-react": "^0.176.0", 45 | "mysql2": "^3.9.1", 46 | "next": "13.3.1", 47 | "postcss": "8.4.23", 48 | "pusher": "^5.1.2", 49 | "pusher-js": "^8.0.2", 50 | "react": "18.2.0", 51 | "react-dom": "18.2.0", 52 | "react-hook-form": "^7.43.9", 53 | "react-icons": "^4.8.0", 54 | "react-loader-spinner": "^5.3.4", 55 | "server-only": "^0.0.1", 56 | "superjson": "^1.12.3", 57 | "tailwind-merge": "^1.12.0", 58 | "tailwindcss": "3.3.1", 59 | "tailwindcss-animate": "^1.0.5", 60 | "typescript": "5.0.4", 61 | "uuid": "^9.0.0", 62 | "zod": "^3.21.4" 63 | }, 64 | "pnpm": { 65 | "patchedDependencies": { 66 | "@tanstack/react-query@4.29.3": "patches/@tanstack__react-query@4.29.3.patch" 67 | } 68 | }, 69 | "devDependencies": { 70 | "@types/uuid": "^9.0.1", 71 | "dotenv": "^16.0.3", 72 | "drizzle-kit": "^0.20.14", 73 | "ts-node": "^10.9.1", 74 | "tsx": "^4.7.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /patches/@tanstack__react-query@4.29.3.patch: -------------------------------------------------------------------------------- 1 | diff --git a/build/lib/reactBatchedUpdates.mjs b/build/lib/reactBatchedUpdates.mjs 2 | index 8a5ec0f3acd8582e6d63573a9479b9cae6b40f88..48c77d58736392bc3712651c978f4f5e48697993 100644 3 | --- a/build/lib/reactBatchedUpdates.mjs 4 | +++ b/build/lib/reactBatchedUpdates.mjs 5 | @@ -1,6 +1,6 @@ 6 | -import * as ReactDOM from 'react-dom'; 7 | - 8 | -const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates; 9 | +const unstable_batchedUpdates = (callback) => { 10 | + callback() 11 | +} 12 | 13 | export { unstable_batchedUpdates }; 14 | //# sourceMappingURL=reactBatchedUpdates.mjs.map -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/@trpc/next-layout/client/createHydrateClient.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo } from "react"; 4 | import { type DehydratedState, Hydrate } from "@tanstack/react-query"; 5 | import { type DataTransformer } from "@trpc/server"; 6 | 7 | export function createHydrateClient(opts: { transformer?: DataTransformer }) { 8 | return function HydrateClient(props: { 9 | children: React.ReactNode; 10 | state: DehydratedState; 11 | }) { 12 | const { state, children } = props; 13 | 14 | const transformedState: DehydratedState = useMemo(() => { 15 | if (opts.transformer) { 16 | return opts.transformer.deserialize(state) as DehydratedState; 17 | } 18 | return state; 19 | }, [state]); 20 | 21 | 22 | return {children}; 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/@trpc/next-layout/client/createTrpcNextBeta.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useMemo, useState } from "react"; 4 | import { QueryClientProvider } from "@tanstack/react-query"; 5 | import type { CreateTRPCClientOptions } from "@trpc/client"; 6 | import { 7 | createHooksInternal, 8 | createReactProxyDecoration, 9 | createReactQueryUtilsProxy, 10 | getQueryClient, 11 | type CreateReactUtilsProxy, 12 | type CreateTRPCReactOptions, 13 | type CreateTRPCReactQueryClientConfig, 14 | type DecoratedProcedureRecord, 15 | } from "@trpc/react-query/shared"; 16 | import type { AnyRouter, ProtectedIntersection } from "@trpc/server"; 17 | import { createFlatProxy } from "@trpc/server/shared"; 18 | 19 | export type WithTRPCConfig = 20 | CreateTRPCClientOptions & CreateTRPCReactQueryClientConfig; 21 | 22 | type WithTRPCOptions = 23 | CreateTRPCReactOptions & WithTRPCConfig; 24 | 25 | /** 26 | * @internal 27 | */ 28 | export interface CreateTRPCNextBase { 29 | useContext(): CreateReactUtilsProxy; 30 | Provider: ({ children }: { children: React.ReactNode }) => JSX.Element; 31 | } 32 | 33 | /** 34 | * @internal 35 | */ 36 | export type CreateTRPCNext< 37 | TRouter extends AnyRouter, 38 | TFlags 39 | > = ProtectedIntersection< 40 | CreateTRPCNextBase, 41 | DecoratedProcedureRecord 42 | >; 43 | 44 | export function createTRPCNextBeta( 45 | opts: WithTRPCOptions 46 | ): CreateTRPCNext { 47 | const trpc = createHooksInternal({ 48 | unstable_overrides: opts.unstable_overrides, 49 | }); 50 | 51 | const TRPCProvider = ({ children }: { children: React.ReactNode }) => { 52 | const [prepassProps] = useState(() => { 53 | const queryClient = getQueryClient(opts); 54 | const trpcClient = trpc.createClient(opts); 55 | return { 56 | queryClient, 57 | trpcClient, 58 | }; 59 | }); 60 | 61 | const { queryClient, trpcClient } = prepassProps; 62 | 63 | return ( 64 | 65 | 66 | {children} 67 | 68 | 69 | ); 70 | }; 71 | 72 | return createFlatProxy((key) => { 73 | if (key === "useContext") { 74 | return () => { 75 | const context = trpc.useContext(); 76 | // create a stable reference of the utils context 77 | return useMemo(() => { 78 | return (createReactQueryUtilsProxy as any)(context); 79 | }, [context]); 80 | }; 81 | } 82 | 83 | if (key === "Provider") { 84 | return TRPCProvider; 85 | } 86 | 87 | return createReactProxyDecoration(key, trpc); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /src/@trpc/next-layout/client/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./createTrpcNextBeta"; 2 | export * from "./createHydrateClient"; -------------------------------------------------------------------------------- /src/@trpc/next-layout/server/createTrpcNextLayout.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, dehydrate } from "@tanstack/query-core"; 2 | import type { DehydratedState } from "@tanstack/react-query"; 3 | import type { 4 | AnyProcedure, 5 | AnyQueryProcedure, 6 | AnyRouter, 7 | DataTransformer, 8 | MaybePromise, 9 | ProcedureRouterRecord, 10 | ProcedureType, 11 | inferProcedureInput, 12 | inferProcedureOutput, 13 | inferRouterContext, 14 | } from "@trpc/server"; 15 | import { createRecursiveProxy } from "@trpc/server/shared"; 16 | 17 | import { getRequestStorage } from "./local-storage"; 18 | import "server-only"; 19 | 20 | interface CreateTRPCNextLayoutOptions { 21 | router: TRouter; 22 | createContext: () => MaybePromise>; 23 | transformer?: DataTransformer; 24 | } 25 | 26 | /** 27 | * @internal 28 | */ 29 | export type DecorateProcedure = 30 | TProcedure extends AnyQueryProcedure 31 | ? { 32 | fetch( 33 | input: inferProcedureInput 34 | ): Promise>; 35 | fetchInfinite( 36 | input: inferProcedureInput 37 | ): Promise>; 38 | } 39 | : never; 40 | 41 | type OmitNever = Pick< 42 | TType, 43 | { 44 | [K in keyof TType]: TType[K] extends never ? never : K; 45 | }[keyof TType] 46 | >; 47 | /** 48 | * @internal 49 | */ 50 | export type DecoratedProcedureRecord< 51 | TProcedures extends ProcedureRouterRecord, 52 | TPath extends string = "" 53 | > = OmitNever<{ 54 | [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter 55 | ? DecoratedProcedureRecord< 56 | TProcedures[TKey]["_def"]["record"], 57 | `${TPath}${TKey & string}.` 58 | > 59 | : TProcedures[TKey] extends AnyQueryProcedure 60 | ? DecorateProcedure 61 | : never; 62 | }>; 63 | 64 | type CreateTRPCNextLayout = DecoratedProcedureRecord< 65 | TRouter["_def"]["record"] 66 | > & { 67 | dehydrate(): Promise; 68 | }; 69 | 70 | function getQueryKey( 71 | path: string[], 72 | input: unknown, 73 | isFetchInfinite?: boolean 74 | ) { 75 | return input === undefined 76 | ? [path, { type: isFetchInfinite ? "infinite" : "query" }] // We added { type: "infinite" | "query" }, because it is how trpc v10.0 format the new queryKeys 77 | : [ 78 | path, 79 | { 80 | input: { ...input }, 81 | type: isFetchInfinite ? "infinite" : "query", 82 | }, 83 | ]; 84 | } 85 | 86 | export function createTRPCNextLayout( 87 | opts: CreateTRPCNextLayoutOptions 88 | ): CreateTRPCNextLayout { 89 | function getState() { 90 | const requestStorage = getRequestStorage<{ 91 | _trpc: { 92 | queryClient: QueryClient; 93 | context: inferRouterContext; 94 | }; 95 | }>(); 96 | requestStorage._trpc = requestStorage._trpc ?? { 97 | cache: Object.create(null), 98 | context: opts.createContext(), 99 | queryClient: new QueryClient({ 100 | defaultOptions: { 101 | queries: { 102 | refetchOnWindowFocus: false, 103 | }, 104 | }, 105 | }), 106 | }; 107 | return requestStorage._trpc; 108 | } 109 | const transformer = opts.transformer ?? { 110 | serialize: (v) => v, 111 | deserialize: (v) => v, 112 | }; 113 | 114 | return createRecursiveProxy(async (callOpts) => { 115 | const path = [...callOpts.path]; 116 | const lastPart = path.pop(); 117 | const state = getState(); 118 | const ctx = state.context; 119 | const { queryClient } = state; 120 | 121 | if (lastPart === "dehydrate" && path.length === 0) { 122 | if (queryClient.isFetching()) { 123 | await new Promise((resolve) => { 124 | const unsub = queryClient.getQueryCache().subscribe((event) => { 125 | if (event?.query.getObserversCount() === 0) { 126 | resolve(); 127 | unsub(); 128 | } 129 | }); 130 | }); 131 | } 132 | const dehydratedState = dehydrate(queryClient); 133 | 134 | return transformer.serialize(dehydratedState); 135 | } 136 | 137 | const fullPath = path.join("."); 138 | const procedure = opts.router._def.procedures[fullPath] as AnyProcedure; 139 | 140 | const type: ProcedureType = "query"; 141 | 142 | const input = callOpts.args[0]; 143 | const queryKey = getQueryKey(path, input, lastPart === "fetchInfinite"); 144 | 145 | if (lastPart === "fetchInfinite") { 146 | return queryClient.fetchInfiniteQuery(queryKey, () => 147 | procedure({ 148 | rawInput: input, 149 | path: fullPath, 150 | ctx, 151 | type, 152 | }) 153 | ); 154 | } 155 | 156 | return queryClient.fetchQuery(queryKey, () => 157 | procedure({ 158 | rawInput: input, 159 | path: fullPath, 160 | ctx, 161 | type, 162 | }) 163 | ); 164 | }) as CreateTRPCNextLayout; 165 | } 166 | -------------------------------------------------------------------------------- /src/@trpc/next-layout/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./createTrpcNextLayout"; -------------------------------------------------------------------------------- /src/@trpc/next-layout/server/local-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file makes sure that we can get a storage that is unique to the current request context 3 | */ 4 | 5 | import { type AsyncLocalStorage } from "async_hooks"; 6 | import { requestAsyncStorage as asyncStorage } from "next/dist/client/components/request-async-storage"; 7 | 8 | function throwError(msg: string) { 9 | throw new Error(msg); 10 | } 11 | 12 | export function getRequestStorage(): T { 13 | if ("getStore" in asyncStorage) { 14 | return ( 15 | (asyncStorage as AsyncLocalStorage).getStore() ?? 16 | throwError("Couldn't get async storage") 17 | ); 18 | } 19 | 20 | return asyncStorage as T; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function Layout({ children }: { children: React.ReactNode }) { 2 | return ( 3 |
4 | {children} 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs/app-beta"; 2 | 3 | interface PageProps { 4 | searchParams: { 5 | [key: string]: unknown; 6 | }; 7 | } 8 | export default function Page({ searchParams }: PageProps) { 9 | const { redirect_url: redirectUrlFromParams } = searchParams || {}; 10 | 11 | let redirectUrl = "/"; 12 | if (redirectUrlFromParams && typeof redirectUrlFromParams === "string") { 13 | redirectUrl = redirectUrlFromParams; 14 | } 15 | 16 | return ; 17 | } 18 | 19 | export const runtime = "experimental-edge"; 20 | export const revalidate = 0; 21 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs/app-beta"; 2 | 3 | interface PageProps { 4 | searchParams: { 5 | [key: string]: unknown; 6 | }; 7 | } 8 | export default function Page({ searchParams }: PageProps) { 9 | const { redirect_url: redirectUrlFromParams } = searchParams || {}; 10 | 11 | let redirectUrl = "/"; 12 | if (redirectUrlFromParams && typeof redirectUrlFromParams === "string") { 13 | redirectUrl = redirectUrlFromParams; 14 | } 15 | 16 | return ; 17 | } 18 | 19 | export const runtime = "experimental-edge"; 20 | export const revalidate = 0; 21 | -------------------------------------------------------------------------------- /src/app/(protected)/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import FeedContents from "@/components/FeedContents"; 2 | import TitleHead from "@/components/heads/TitleHead"; 3 | 4 | export default function Home() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export const runtime = "experimental-edge"; 14 | export const revalidate = 0; 15 | -------------------------------------------------------------------------------- /src/app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Sidebar from "@/components/Sidebar"; 2 | 3 | export default function Layout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 |
10 | 11 |
12 | {children} 13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/app/(protected)/notifications/page.tsx: -------------------------------------------------------------------------------- 1 | import TitleHead from "@/components/heads/TitleHead"; 2 | import NotificationLists from "@/components/lists/NotificationLists"; 3 | 4 | export default function Notifications() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/(protected)/posts/[postId]/page.tsx: -------------------------------------------------------------------------------- 1 | import PostCard from "@/components/cards/PostCard"; 2 | import TitleHead from "@/components/heads/TitleHead"; 3 | import PostCommentLists from "@/components/lists/PostCommentLists"; 4 | import { RouterOutputs } from "@/lib/api/client"; 5 | import { api } from "@/lib/api/server"; 6 | import { notFound } from "next/navigation"; 7 | 8 | interface PageProps { 9 | params: { 10 | postId: string; 11 | }; 12 | } 13 | export default async function PostIdPage({ params: { postId } }: PageProps) { 14 | if (!postId || typeof postId !== "string") { 15 | return notFound(); 16 | } 17 | 18 | let post: RouterOutputs["post"]["postById"] | undefined; 19 | try { 20 | post = await api.post.postById.fetch({ 21 | id: postId, 22 | }); 23 | } catch (_) { 24 | return notFound(); 25 | } 26 | 27 | if (!post) return notFound(); 28 | 29 | return ( 30 | <> 31 | 32 | 39 | 40 | 41 | ); 42 | } 43 | 44 | export const runtime = "experimental-edge"; 45 | export const revalidate = 0; 46 | -------------------------------------------------------------------------------- /src/app/(protected)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import Profile from "@/components/Profile"; 2 | import ProfileContents from "@/components/ProfileContents"; 3 | import { api } from "@/lib/api/server"; 4 | import { notFound } from "next/navigation"; 5 | 6 | export default async function Page() { 7 | const profile = await api.github.profile.fetch(); 8 | if (!profile) return notFound(); 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | export const revalidate = 0; 19 | -------------------------------------------------------------------------------- /src/app/(protected)/repos/[repoId]/page.tsx: -------------------------------------------------------------------------------- 1 | import RepoCard from "@/components/cards/RepoCard"; 2 | import TitleHead from "@/components/heads/TitleHead"; 3 | import RepoPostLists from "@/components/lists/RepoPostLists"; 4 | import { convertToRepoName } from "@/helpers/repoId"; 5 | import { api } from "@/lib/api/server"; 6 | import { notFound } from "next/navigation"; 7 | 8 | interface PageProps { 9 | params: { 10 | repoId: string; 11 | }; 12 | } 13 | 14 | export default async function RepoIdPage({ params: { repoId } }: PageProps) { 15 | const repoName = convertToRepoName(repoId); 16 | 17 | const repo = await api.github.getARepo.fetch({ repoName }); 18 | if (!repo) return notFound(); 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | export const runtime = "experimental-edge"; 30 | export const revalidate = 0; 31 | -------------------------------------------------------------------------------- /src/app/(protected)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import SearchSection from "@/components/SearchSection"; 2 | import TitleHead from "@/components/heads/TitleHead"; 3 | 4 | export default function Search() { 5 | return ( 6 | <> 7 | 8 | 9 | 10 | ); 11 | } 12 | 13 | export const runtime = "experimental-edge"; 14 | export const revalidate = 0; 15 | -------------------------------------------------------------------------------- /src/app/(protected)/users/[username]/page.tsx: -------------------------------------------------------------------------------- 1 | import Profile from "@/components/Profile"; 2 | import ProfileContents from "@/components/ProfileContents"; 3 | import { api } from "@/lib/api/server"; 4 | import { notFound, redirect } from "next/navigation"; 5 | 6 | interface PageProps { 7 | params: { 8 | username: string; 9 | }; 10 | } 11 | 12 | export default async function Users({ params: { username } }: PageProps) { 13 | const [myProfile, pageProfile] = await Promise.all([ 14 | api.github.profile.fetch(), 15 | api.github.otherProfile.fetch({ username }), 16 | ]); 17 | 18 | if (!pageProfile || !myProfile) return notFound(); 19 | 20 | if (myProfile.login === pageProfile.login) { 21 | return redirect("/profile"); 22 | } 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export const runtime = "experimental-edge"; 33 | export const revalidate = 0; 34 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import { Inter } from "next/font/google"; 3 | import { cn } from "@/lib/utils"; 4 | import { ClientProviders } from "@/providers/ClientProviders"; 5 | 6 | const inter = Inter({ subsets: ["latin"] }); 7 | 8 | export const metadata = { 9 | title: "GH Social", 10 | description: "Full-stack Edge App", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode; 17 | }) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from "@/components/Loader"; 2 | 3 | export default function LoadingPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/lib/api/client"; 4 | import { useToast } from "./ui/use-toast"; 5 | import { useForm } from "react-hook-form"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { CreateCommentInput, createCommentSchema } from "@/validationSchemas"; 8 | import { Input } from "./ui/input"; 9 | import { Button } from "./ui/button"; 10 | 11 | interface Props { 12 | postId: string; 13 | } 14 | const CommentForm = ({ postId }: Props) => { 15 | const { toast } = useToast(); 16 | const utils = api.useContext(); 17 | 18 | const { 19 | register, 20 | handleSubmit, 21 | reset, 22 | formState: { errors }, 23 | } = useForm({ 24 | defaultValues: { 25 | content: "", 26 | postId, 27 | }, 28 | resolver: zodResolver(createCommentSchema), 29 | }); 30 | 31 | const { mutate, isLoading } = api.comment.create.useMutation({ 32 | onSuccess: () => { 33 | reset(); 34 | utils.comment.invalidate(); 35 | utils.post.invalidate(); 36 | toast({ 37 | title: "Success!", 38 | description: "Reply added", 39 | }); 40 | }, 41 | onError: (err) => 42 | toast({ 43 | title: "Uh oh..", 44 | description: err.message, 45 | variant: "destructive", 46 | }), 47 | }); 48 | 49 | const onSubmit = (inputs: CreateCommentInput) => mutate(inputs); 50 | 51 | return ( 52 |
57 |
58 | 62 | 65 |
66 | {errors.content && ( 67 | {errors.content.message} 68 | )} 69 |
70 | ); 71 | }; 72 | 73 | export default CommentForm; 74 | -------------------------------------------------------------------------------- /src/components/FeedContents.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import FollowingFeedLists from "./lists/feeds/FollowingFeedLists"; 4 | import HotPostLists from "./lists/feeds/HotPostLists"; 5 | import LatestPostLists from "./lists/feeds/LatestPostLists"; 6 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; 7 | 8 | const FeedContents = () => { 9 | return ( 10 |
11 | 12 | 13 | Hot 14 | Following 15 | Latest 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | ); 29 | }; 30 | 31 | export default FeedContents; 32 | -------------------------------------------------------------------------------- /src/components/FollowAction.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/lib/api/client"; 4 | import { useToast } from "./ui/use-toast"; 5 | import React from "react"; 6 | import { 7 | AlertDialog, 8 | AlertDialogAction, 9 | AlertDialogCancel, 10 | AlertDialogContent, 11 | AlertDialogFooter, 12 | AlertDialogHeader, 13 | AlertDialogTitle, 14 | AlertDialogTrigger, 15 | } from "./ui/alert-dialog"; 16 | 17 | interface Props { 18 | username: string; 19 | setFollowers: React.Dispatch>; 20 | } 21 | const FollowAction = ({ username, setFollowers }: Props) => { 22 | const { toast } = useToast(); 23 | const utils = api.useContext(); 24 | 25 | const { isLoading, data: hasFollowed } = 26 | api.github.hasFollowedTheUser.useQuery({ 27 | username, 28 | }); 29 | 30 | const { mutate } = api.github.followAction.useMutation({ 31 | onError: (err) => 32 | toast({ 33 | title: "Oh uh..", 34 | description: err.message, 35 | variant: "destructive", 36 | }), 37 | onSuccess: (res) => { 38 | if (hasFollowed) { 39 | setFollowers((followers) => followers - 1); 40 | } else { 41 | setFollowers((followers) => followers + 1); 42 | } 43 | utils.github.hasFollowedTheUser.invalidate(); 44 | 45 | toast({ 46 | title: res.success ? "Success!" : "Oh uh..", 47 | description: res.message, 48 | variant: res.success ? "default" : "destructive", 49 | }); 50 | }, 51 | }); 52 | 53 | const handleAction = () => { 54 | const action = hasFollowed ? "unfollow" : "follow"; 55 | mutate({ 56 | action, 57 | username, 58 | }); 59 | }; 60 | 61 | return ( 62 | 63 | 67 | {isLoading ? "Loading" : hasFollowed ? "Unfollow" : "Follow"} 68 | 69 | 70 | 71 | 72 | Are you sure you want to {hasFollowed ? "unfollow" : "follow"} this 73 | user? 74 | 75 | 76 | 77 | Cancel 78 | Continue 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default FollowAction; 86 | -------------------------------------------------------------------------------- /src/components/Followers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/lib/api/client"; 4 | import React, { Fragment } from "react"; 5 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; 6 | import { ScrollArea } from "./ui/scroll-area"; 7 | import UserCardSkeleton from "./skeletons/UserCardSkeleton"; 8 | import { Button } from "./ui/button"; 9 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 10 | import { USER_LISTING_PER_PAGE } from "@/constants"; 11 | import { useRouter } from "next/navigation"; 12 | import usePagination from "@/hooks/usePagination"; 13 | 14 | interface Props { 15 | username: string; 16 | isOpened: boolean; 17 | setIsOpened: React.Dispatch>; 18 | } 19 | const Followers = ({ username, isOpened, setIsOpened }: Props) => { 20 | const router = useRouter(); 21 | const { currentPage, Pagination } = usePagination(); 22 | 23 | const { data, isLoading } = api.github.followers.useQuery( 24 | { 25 | username, 26 | page: currentPage, 27 | perPage: USER_LISTING_PER_PAGE, 28 | }, 29 | { 30 | enabled: isOpened, 31 | } 32 | ); 33 | 34 | return ( 35 | setIsOpened(!isOpened)} modal> 36 | 37 | 38 | Followers 39 | 40 | {isLoading && 41 | [...Array(5)].map((_, idx) => ( 42 | 43 | ))} 44 | {data && data.length > 0 && ( 45 | 46 | {data.map((user) => ( 47 | 48 |
49 |
50 | 51 | 52 | 53 | {user.login.slice(0, 1).toUpperCase()} 54 | 55 | 56 |
{user.login}
57 |
58 | 61 |
62 |
63 | ))} 64 | = USER_LISTING_PER_PAGE} /> 65 |
66 | )} 67 | 68 | {data && data.length === 0 && ( 69 |
No Followers
70 | )} 71 |
72 |
73 | ); 74 | }; 75 | 76 | export default Followers; 77 | -------------------------------------------------------------------------------- /src/components/Following.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/lib/api/client"; 4 | import React, { Fragment } from "react"; 5 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; 6 | import { ScrollArea } from "./ui/scroll-area"; 7 | import UserCardSkeleton from "./skeletons/UserCardSkeleton"; 8 | import { Button } from "./ui/button"; 9 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 10 | import { USER_LISTING_PER_PAGE } from "@/constants"; 11 | import { useRouter } from "next/navigation"; 12 | import usePagination from "@/hooks/usePagination"; 13 | 14 | interface Props { 15 | username: string; 16 | isOpened: boolean; 17 | setIsOpened: React.Dispatch>; 18 | } 19 | const Following = ({ username, isOpened, setIsOpened }: Props) => { 20 | const router = useRouter(); 21 | const { currentPage, Pagination } = usePagination(); 22 | 23 | const { data, isLoading } = api.github.following.useQuery( 24 | { 25 | username, 26 | page: currentPage, 27 | perPage: USER_LISTING_PER_PAGE, 28 | }, 29 | { 30 | enabled: isOpened, 31 | } 32 | ); 33 | 34 | return ( 35 | setIsOpened(!isOpened)} modal> 36 | 37 | 38 | Following 39 | 40 | {isLoading && 41 | [...Array(5)].map((_, idx) => ( 42 | 43 | ))} 44 | {data && data.length > 0 && ( 45 | 46 | {data.map((user) => ( 47 | 48 |
49 |
50 | 51 | 52 | 53 | {user.login.slice(0, 1).toUpperCase()} 54 | 55 | 56 |
{user.login}
57 |
58 | 61 |
62 |
63 | ))} 64 | = USER_LISTING_PER_PAGE} /> 65 |
66 | )} 67 | {data && data.length === 0 && ( 68 |
No Following
69 | )} 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default Following; 76 | -------------------------------------------------------------------------------- /src/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RotatingLines } from "react-loader-spinner"; 4 | 5 | const Loader = () => { 6 | return ( 7 |
8 | 15 |
16 | ); 17 | }; 18 | 19 | export default Loader; 20 | -------------------------------------------------------------------------------- /src/components/PostForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePostModalContext } from "@/providers/PostModalProvider"; 4 | import { Dialog, DialogContent, DialogHeader } from "./ui/dialog"; 5 | import { DialogTitle } from "./ui/dialog"; 6 | import RepoCard from "./cards/RepoCard"; 7 | import { useToast } from "./ui/use-toast"; 8 | import { api } from "@/lib/api/client"; 9 | import { useForm } from "react-hook-form"; 10 | import { CreatePostInput, createPostSchema } from "@/validationSchemas"; 11 | import { zodResolver } from "@hookform/resolvers/zod"; 12 | import { Button } from "./ui/button"; 13 | 14 | const PostForm = () => { 15 | const { isOpened, handleClose, repo } = usePostModalContext(); 16 | 17 | const { toast } = useToast(); 18 | const utils = api.useContext(); 19 | 20 | const { 21 | register, 22 | handleSubmit, 23 | reset, 24 | formState: { errors }, 25 | } = useForm({ 26 | defaultValues: { 27 | repoShared: repo?.full_name, 28 | content: "", 29 | }, 30 | resolver: zodResolver(createPostSchema), 31 | }); 32 | 33 | const { mutate, isLoading: isPosting } = api.post.create.useMutation({ 34 | onSuccess: () => { 35 | utils.post.invalidate(); 36 | reset(); 37 | handleClose(); 38 | toast({ 39 | title: "Success!", 40 | description: "Successfully added the post", 41 | }); 42 | }, 43 | onError: (err) => 44 | toast({ 45 | title: "Uh oh..", 46 | description: err.message, 47 | variant: "destructive", 48 | }), 49 | }); 50 | 51 | const onSubmit = (inputs: CreatePostInput) => 52 | mutate({ 53 | ...inputs, 54 | repoShared: repo?.full_name, 55 | }); 56 | 57 | return ( 58 | 59 | 60 | 61 | New Post 62 | 63 |
64 | 69 | {errors.content && ( 70 | 71 | {errors.content.message} 72 | 73 | )} 74 | {repo && } 75 |
76 | 84 |
85 | 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default PostForm; 92 | -------------------------------------------------------------------------------- /src/components/Profile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; 4 | import { TrimmedGitHubProfile } from "@/types/github"; 5 | import Link from "next/link"; 6 | import { Separator } from "./ui/separator"; 7 | import { AiOutlineLink } from "react-icons/ai"; 8 | import { BsFillBuildingFill } from "react-icons/bs"; 9 | import Followers from "./Followers"; 10 | import { useState } from "react"; 11 | import Following from "./Following"; 12 | import FollowAction from "./FollowAction"; 13 | import { useToast } from "./ui/use-toast"; 14 | import { displayNumbers } from "@/helpers/displayNumbers"; 15 | import TitleHead from "./heads/TitleHead"; 16 | import { cn } from "@/lib/utils"; 17 | 18 | interface Props { 19 | profile: TrimmedGitHubProfile; 20 | self?: boolean; 21 | } 22 | const Profile = ({ profile, self = false }: Props) => { 23 | const { toast } = useToast(); 24 | 25 | // had to do this because `.invalidate()` doesn't work for cached data from server-side trpc 26 | const [followers, setFollowers] = useState(profile.followers ?? 0); 27 | 28 | const [isFollowersModalOpened, setIsFollowersModalOpened] = useState(false); 29 | const [isFollowingModalOpened, setIsFollowingModalOpened] = useState(false); 30 | 31 | const handleOpen = (type: "following" | "followers") => { 32 | if (!self && profile.type !== "User") { 33 | return toast({ 34 | title: "Not Allowed", 35 | description: "This profile restricts third-party access", 36 | }); 37 | } 38 | 39 | if (type === "followers") { 40 | return setIsFollowersModalOpened(true); 41 | } else { 42 | return setIsFollowingModalOpened(true); 43 | } 44 | }; 45 | 46 | return ( 47 | <> 48 | {!self && } 49 |
50 |
56 |
57 |
58 | 59 | 60 | 61 | {profile.login.slice(0, 1).toUpperCase()} 62 | 63 | 64 |

{profile.name}

65 |

@{profile.login}

66 |

{profile.bio}

67 | 68 | {(profile.blog || profile.company) && ( 69 |
70 | {profile.company && ( 71 |
72 | 73 | {profile.company} 74 |
75 | )} 76 | {profile.blog && ( 77 | 78 |
79 | 80 | {profile.blog} 81 |
82 | 83 | )} 84 |
85 | )} 86 |
87 |
88 |
89 |

handleOpen("followers")} 92 | > 93 | {displayNumbers(followers)} Followers 94 |

95 | 96 |

handleOpen("following")} 99 | > 100 | {displayNumbers(profile.following ?? 0)} Following 101 |

102 |
103 | {!self && ( 104 |
105 | 109 |
110 | )} 111 |
112 |
113 |
114 | 119 | 124 | 125 | ); 126 | }; 127 | 128 | export default Profile; 129 | -------------------------------------------------------------------------------- /src/components/ProfileContents.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TrimmedGitHubProfile } from "@/types/github"; 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 5 | import MyRepoLists from "./lists/MyRepoLists"; 6 | import RepoLists from "./lists/RepoLists"; 7 | import MyPostLists from "./lists/MyPostLists"; 8 | import MyLikedPostLists from "./lists/MyLikedPostLists"; 9 | import PostLists from "./lists/PostLists"; 10 | import LikedPostLists from "./lists/LikedPostLists"; 11 | import MyCommentLists from "./lists/MyCommentLists"; 12 | import CommentLists from "./lists/CommentLists"; 13 | 14 | interface Props { 15 | profile: TrimmedGitHubProfile; 16 | self?: boolean; 17 | } 18 | const ProfileContents = ({ profile, self = false }: Props) => { 19 | return ( 20 |
21 | 22 | 23 | Posts 24 | Repos 25 | Replies 26 | Likes 27 | 28 | 29 | {self ? : } 30 | 31 | 32 | {self ? : } 33 | 34 | 35 | {self ? ( 36 | 37 | ) : ( 38 | 39 | )} 40 | 41 | 42 | {self ? ( 43 | 44 | ) : ( 45 | 46 | )} 47 | 48 | 49 |
50 | ); 51 | }; 52 | 53 | export default ProfileContents; 54 | -------------------------------------------------------------------------------- /src/components/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import usePagination from "@/hooks/usePagination"; 5 | import { api } from "@/lib/api/client"; 6 | import CardSkeleton from "./skeletons/CardSkeleton"; 7 | import PostCard from "./cards/PostCard"; 8 | 9 | interface Props { 10 | query: string; 11 | } 12 | const SearchResults = ({ query }: Props) => { 13 | const { Pagination, currentPage } = usePagination(); 14 | 15 | const { isLoading, data: posts } = api.post.searchPosts.useQuery({ 16 | query, 17 | page: currentPage, 18 | perPage: POST_LISTING_PER_PAGE, 19 | }); 20 | 21 | if (isLoading) 22 | return ( 23 | <> 24 | {[...Array(10)].map((_, idx) => ( 25 | 26 | ))} 27 | 28 | ); 29 | 30 | return ( 31 | <> 32 | {posts && posts.length === 0 && ( 33 |
34 | Uh oh.. no post found. 35 |
36 | )} 37 | {posts && 38 | posts.length > 0 && 39 | posts.map((data) => )} 40 | {posts && = POST_LISTING_PER_PAGE} />} 41 | 42 | ); 43 | }; 44 | 45 | export default SearchResults; 46 | -------------------------------------------------------------------------------- /src/components/SearchSection.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Input } from "./ui/input"; 5 | import { Button } from "./ui/button"; 6 | import SearchResults from "./SearchResults"; 7 | 8 | const SearchSection = () => { 9 | const [input, setInput] = useState(""); 10 | const [query, setQuery] = useState(""); 11 | 12 | const handleSubmit = () => { 13 | setQuery(input); 14 | }; 15 | 16 | return ( 17 |
18 |
19 | setInput(e.target.value)} 23 | /> 24 | 32 |
33 | {!query ? ( 34 |
35 | Start typing to get some results 36 |
37 | ) : ( 38 | 39 | )} 40 |
41 | ); 42 | }; 43 | 44 | export default SearchSection; 45 | -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { 5 | AiOutlineHome, 6 | AiFillHome, 7 | AiFillBell, 8 | AiOutlineBell, 9 | } from "react-icons/ai"; 10 | import { IoSearchOutline, IoSearchSharp } from "react-icons/io5"; 11 | import { RiUser3Fill, RiUser3Line } from "react-icons/ri"; 12 | import { BiMessageSquareAdd } from "react-icons/bi"; 13 | import { usePathname, useRouter } from "next/navigation"; 14 | import { useEffect, useMemo } from "react"; 15 | import { UserButton, useUser } from "@clerk/nextjs"; 16 | import { dark } from "@clerk/themes"; 17 | import { usePostModalContext } from "@/providers/PostModalProvider"; 18 | 19 | export default function Sidebar() { 20 | const { user } = useUser(); 21 | const { handleOpen: handleOpenPostModal } = usePostModalContext(); 22 | 23 | const location = usePathname(); 24 | const router = useRouter(); 25 | 26 | const handleRoute = (route: string) => router.push(route); 27 | 28 | const navs = useMemo(() => { 29 | const basePath = location?.split("/")?.[1]; 30 | 31 | const routes = [ 32 | { 33 | Active: AiFillHome, 34 | InActive: AiOutlineHome, 35 | title: "Home", 36 | path: "/", 37 | }, 38 | { 39 | Active: IoSearchSharp, 40 | InActive: IoSearchOutline, 41 | title: "Search", 42 | path: "/search", 43 | }, 44 | { 45 | Active: AiFillBell, 46 | InActive: AiOutlineBell, 47 | title: "Notifications", 48 | path: "/notifications", 49 | }, 50 | { 51 | Active: RiUser3Fill, 52 | InActive: RiUser3Line, 53 | title: "Profile", 54 | path: "/profile", 55 | }, 56 | ]; 57 | if (location === "/") { 58 | return [ 59 | { 60 | Icon: routes[0].Active, 61 | title: routes[0].title, 62 | path: routes[0].path, 63 | isActive: true, 64 | }, 65 | ...routes.slice(1).map((route) => ({ 66 | Icon: route.InActive, 67 | title: route.title, 68 | path: route.path, 69 | isActive: false, 70 | })), 71 | ]; 72 | } else { 73 | return routes.map((route, idx) => { 74 | const isActive = idx !== 0 && `/${basePath}`.includes(route.path); 75 | return { 76 | Icon: isActive ? route.Active : route.InActive, 77 | title: route.title, 78 | path: route.path, 79 | isActive, 80 | }; 81 | }); 82 | } 83 | }, [location]); 84 | 85 | /** 86 | * Prefetch routes when first-time rendering this component 87 | */ 88 | useEffect(() => { 89 | navs.map((nav) => { 90 | if (!nav.isActive) router.prefetch(nav.path); 91 | }); 92 | // eslint-disable-next-line react-hooks/exhaustive-deps 93 | }, []); 94 | 95 | return ( 96 |
97 |
98 | {navs.map(({ Icon, title, isActive, path }) => ( 99 |
handleRoute(path)} 106 | > 107 | 108 |

{title}

109 |
110 | ))} 111 |
handleOpenPostModal()} 114 | > 115 | 116 |

Add Post

117 |
118 | 119 |
120 | 126 | {user && ( 127 |
128 |

129 | {user?.fullName} 130 |

131 |

@{user?.username}

132 |
133 | )} 134 |
135 |
136 |
137 | ); 138 | } 139 | -------------------------------------------------------------------------------- /src/components/cards/CommentCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RouterOutputs, api } from "@/lib/api/client"; 4 | import { TrimmedGitHubProfile } from "@/types/github"; 5 | import { useToast } from "../ui/use-toast"; 6 | import AvatarSkeleton from "../skeletons/AvatarSkeleton"; 7 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 8 | import { useRouter } from "next/navigation"; 9 | import { formatTimeAgo } from "@/helpers/formatTimeAgo"; 10 | import { useUser } from "@clerk/nextjs"; 11 | import { 12 | AlertDialog, 13 | AlertDialogAction, 14 | AlertDialogCancel, 15 | AlertDialogContent, 16 | AlertDialogDescription, 17 | AlertDialogFooter, 18 | AlertDialogHeader, 19 | AlertDialogTitle, 20 | AlertDialogTrigger, 21 | } from "../ui/alert-dialog"; 22 | import { cn } from "@/lib/utils"; 23 | import { MdOutlineDelete } from "react-icons/md"; 24 | 25 | interface Props { 26 | comment: RouterOutputs["comment"]["commentsByPostId"][number]; 27 | owner?: TrimmedGitHubProfile; 28 | navigateToPost?: boolean; 29 | } 30 | const CommentCard = ({ comment, owner, navigateToPost = true }: Props) => { 31 | const { toast } = useToast(); 32 | const { user } = useUser(); 33 | const router = useRouter(); 34 | const utils = api.useContext(); 35 | 36 | const { isLoading: isLoadingProfile, data: profile } = 37 | api.github.otherProfile.useQuery( 38 | { 39 | username: comment.ownerId, 40 | }, 41 | { 42 | enabled: !owner, 43 | } 44 | ); 45 | 46 | const { mutate } = api.comment.deleteById.useMutation({ 47 | onSuccess: () => { 48 | utils.comment.invalidate(); 49 | utils.post.invalidate(); 50 | toast({ 51 | title: "Success!", 52 | description: "Successfully deleted the comment", 53 | }); 54 | }, 55 | onError: (err) => { 56 | toast({ 57 | title: "Oh uh..", 58 | description: err.message, 59 | variant: "destructive", 60 | }); 61 | }, 62 | }); 63 | 64 | const readProfile = () => router.push(`/users/${comment.ownerId}`); 65 | const readPost = () => 66 | navigateToPost && router.push(`/posts/${comment.postId}`); 67 | const handleDelete = () => 68 | user?.username === comment.ownerId && mutate({ id: comment.id }); 69 | const displayLoaderProfile = !owner ? isLoadingProfile : false; 70 | const commentOwner = owner ?? profile; 71 | 72 | return ( 73 |
74 | {displayLoaderProfile && } 75 | {commentOwner && ( 76 |
77 | 78 | 82 | 83 | {commentOwner.login.slice(0, 1).toUpperCase()} 84 | 85 | 86 |
87 |

88 | {commentOwner.name} 89 |

90 |

@{commentOwner.login}

91 |
92 |
93 | {" "} 94 | | Replied {formatTimeAgo(comment.createdAt)} 95 |
96 |
97 | )} 98 |

105 | {comment.content} 106 |

107 | {user?.username === comment.ownerId && ( 108 | 109 | 110 |
111 | 112 |

Delete

113 |
114 |
115 | 116 | 117 | 118 | Are you sure you want to delete this comment? 119 | 120 | 121 | This action cannot be undone. 122 | 123 | 124 | 125 | Cancel 126 | 127 | Continue 128 | 129 | 130 | 131 |
132 | )} 133 |
134 | ); 135 | }; 136 | 137 | export default CommentCard; 138 | -------------------------------------------------------------------------------- /src/components/cards/NotificationCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RouterOutputs, api } from "@/lib/api/client"; 4 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 5 | import { useRouter } from "next/navigation"; 6 | import AvatarSkeleton from "../skeletons/AvatarSkeleton"; 7 | import { formatTimeAgo } from "@/helpers/formatTimeAgo"; 8 | import CardSkeleton from "../skeletons/CardSkeleton"; 9 | import RepoCard from "./RepoCard"; 10 | import PostCard from "./PostCard"; 11 | import CommentCard from "./CommentCard"; 12 | import { convertToRepoId } from "@/helpers/repoId"; 13 | 14 | interface Props { 15 | notification: RouterOutputs["notification"]["getRecents"][number]; 16 | } 17 | const NotificationCard = ({ notification }: Props) => { 18 | const router = useRouter(); 19 | 20 | const { isLoading: isLoadingProfile, data: profile } = 21 | api.github.otherProfile.useQuery({ 22 | username: notification.originId, 23 | }); 24 | 25 | /** 26 | * Post action 27 | */ 28 | const displayPost = !!( 29 | notification.postAction && 30 | notification.postAction !== "comment" && 31 | notification.postId 32 | ); 33 | const { isLoading: isLoadingPost, data: post } = api.post.postById.useQuery( 34 | { 35 | id: notification.postId!!, 36 | }, 37 | { 38 | enabled: displayPost, 39 | } 40 | ); 41 | const displayLoaderPost = displayPost ? isLoadingPost : false; 42 | 43 | /** 44 | * Comment action 45 | */ 46 | const displayComment = !!( 47 | notification.postAction === "comment" && notification.commentId 48 | ); 49 | const { isLoading: isLoadingComment, data: comment } = 50 | api.comment.commentById.useQuery( 51 | { 52 | id: notification.commentId!, 53 | }, 54 | { 55 | enabled: displayComment, 56 | } 57 | ); 58 | const displayLoaderComment = displayComment ? isLoadingComment : false; 59 | 60 | /** 61 | * Repos action 62 | */ 63 | const displayRepo = !!( 64 | notification.githubAction && 65 | notification.githubAction !== "follow" && 66 | notification.repoName 67 | ); 68 | const { isLoading: isLoadingRepo, data: repoShared } = 69 | api.github.getARepo.useQuery( 70 | { 71 | repoName: notification.repoName!, 72 | }, 73 | { 74 | enabled: displayRepo, 75 | } 76 | ); 77 | const displayLoaderRepo = displayRepo ? isLoadingRepo : false; 78 | 79 | const readProfile = () => router.push(`/users/${notification.originId}`); 80 | 81 | const goToReference = () => { 82 | const { githubAction, repoName, postAction, postId, originId } = 83 | notification; 84 | let link = ""; 85 | if (githubAction) { 86 | switch (githubAction) { 87 | case "follow": 88 | link = `/users/${originId}`; 89 | break; 90 | case "share": 91 | if (postId) { 92 | link = `/posts/${postId}`; 93 | } else { 94 | link = `/repos/${convertToRepoId(repoName!)}`; 95 | } 96 | break; 97 | case "star": 98 | link = `/repos/${convertToRepoId(repoName!)}`; 99 | break; 100 | default: 101 | break; 102 | } 103 | } else if (postAction) { 104 | switch (postAction) { 105 | case "comment": 106 | link = `/posts/${postId}#comments`; 107 | break; 108 | case "like": 109 | link = `/posts/${postId}`; 110 | break; 111 | } 112 | } 113 | router.push(link); 114 | }; 115 | 116 | const notificationInfo = (() => { 117 | const { githubAction, postAction } = notification; 118 | let action = ""; 119 | if (githubAction === "follow") action = "started following you"; 120 | if (githubAction === "share") 121 | action = "shared one of your repository in a post"; 122 | if (githubAction === "star") action = "has starred one of your repository"; 123 | if (postAction === "comment") action = "commented in one of your post"; 124 | if (postAction === "like") action = "liked one of your post"; 125 | return action; 126 | })(); 127 | 128 | return ( 129 |
130 | {isLoadingProfile || !profile ? ( 131 | 132 | ) : ( 133 |
134 | 135 | 136 | 137 | {profile.login.slice(0, 1).toUpperCase()} 138 | 139 | 140 |
141 |
145 |

@{profile.login}

146 |
147 |

151 | {notificationInfo} 152 |

153 | 154 | | {formatTimeAgo(notification.createdAt)} 155 | 156 |
157 |
158 | )} 159 | {(displayLoaderRepo || displayLoaderComment || displayLoaderPost) && ( 160 | 161 | )} 162 | {displayRepo && repoShared && } 163 | {displayRepo && !displayLoaderRepo && !repoShared && ( 164 |
165 | Repository doesn't exist 166 |
167 | )} 168 | {displayPost && post && } 169 | {displayPost && !displayLoaderPost && !post && ( 170 |
171 | Post doesn't exist 172 |
173 | )} 174 | {displayComment && comment && } 175 | {displayComment && !displayLoaderComment && !comment && ( 176 |
177 | Comment doesn't exist 178 |
179 | )} 180 |
181 | Posted {formatTimeAgo(notification.createdAt)} 182 |
183 |
184 | ); 185 | }; 186 | 187 | export default NotificationCard; 188 | -------------------------------------------------------------------------------- /src/components/cards/RepoCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { TrimmedGitHubRepo } from "@/types/github"; 4 | import Link from "next/link"; 5 | import { Separator } from "../ui/separator"; 6 | import { 7 | AiOutlineRetweet, 8 | AiFillStar, 9 | AiOutlineStar, 10 | AiOutlineFork, 11 | } from "react-icons/ai"; 12 | import { api } from "@/lib/api/client"; 13 | import { useToast } from "../ui/use-toast"; 14 | import { useState } from "react"; 15 | import { Badge } from "../ui/badge"; 16 | import { displayNumbers } from "@/helpers/displayNumbers"; 17 | import StarSkeleton from "../skeletons/StarSkeleton"; 18 | import { 19 | Tooltip, 20 | TooltipContent, 21 | TooltipProvider, 22 | TooltipTrigger, 23 | } from "../ui/tooltip"; 24 | import { usePostModalContext } from "@/providers/PostModalProvider"; 25 | import { cn } from "@/lib/utils"; 26 | import { convertToRepoId } from "@/helpers/repoId"; 27 | 28 | interface Props { 29 | repo: TrimmedGitHubRepo; 30 | hideCounts?: boolean; 31 | border?: boolean; 32 | navigateToGitHub?: boolean; 33 | } 34 | const RepoCard = ({ 35 | repo, 36 | hideCounts = false, 37 | border = true, 38 | navigateToGitHub = false, 39 | }: Props) => { 40 | const { handleOpen: handleOpenPostModal } = usePostModalContext(); 41 | 42 | const { isLoading: isLoadingHasStarred, data: hasStarred } = 43 | api.github.hasStarredTheRepo.useQuery( 44 | { 45 | repoName: repo.full_name, 46 | }, 47 | { 48 | enabled: !hideCounts, 49 | } 50 | ); 51 | const { isLoading: isLoadingRepoSharedCounts, data: totalShared } = 52 | api.post.repoSharedCounts.useQuery( 53 | { 54 | repoName: repo.full_name, 55 | }, 56 | { 57 | enabled: !hideCounts, 58 | } 59 | ); 60 | const [starCount, setStarCount] = useState(repo.stargazers_count); 61 | 62 | const { toast } = useToast(); 63 | const utils = api.useContext(); 64 | 65 | const { mutate } = api.github.starAction.useMutation({ 66 | onError: (err) => 67 | toast({ 68 | title: "Oh uh..", 69 | description: err.message, 70 | variant: "destructive", 71 | }), 72 | onSuccess: (res) => { 73 | setStarCount((count) => { 74 | if (hasStarred) { 75 | return count - 1; 76 | } else { 77 | return count + 1; 78 | } 79 | }); 80 | utils.github.hasStarredTheRepo.invalidate({ 81 | repoName: repo.full_name, 82 | }); 83 | utils.github.myRepos.invalidate(); 84 | utils.github.otherUserRepos.invalidate(); 85 | 86 | toast({ 87 | title: res.success ? "Success!" : "Oh uh..", 88 | description: res.message, 89 | variant: res.success ? "default" : "destructive", 90 | }); 91 | }, 92 | }); 93 | 94 | const handleStar = () => { 95 | if (isLoadingHasStarred) return; 96 | const action = hasStarred ? "unstar" : "star"; 97 | mutate({ 98 | action, 99 | repoName: repo.full_name, 100 | }); 101 | }; 102 | 103 | const handleShareRepo = () => handleOpenPostModal(repo); 104 | 105 | return ( 106 |
114 |
115 | 123 |

124 | {repo.full_name} 125 |

126 | 127 | {repo.fork && ( 128 |
129 | 130 | This is a forked repository 131 |
132 | )} 133 |

{repo.description}

134 | {repo.topics && ( 135 |
136 | {repo.topics.map((topic) => ( 137 | 142 | {topic} 143 | 144 | ))} 145 |
146 | )} 147 |
148 | {!hideCounts && ( 149 | <> 150 | 151 |
152 | {isLoadingRepoSharedCounts ? ( 153 | 154 | ) : ( 155 | 156 | 157 | 158 |
162 | 163 | {displayNumbers(totalShared ?? 0)} 164 |
165 |
166 | 167 |

Share repo in a post

168 |
169 |
170 |
171 | )} 172 | 173 | 174 | 175 | {isLoadingHasStarred ? ( 176 | 177 | ) : ( 178 | 179 | 180 | 181 |
185 | {hasStarred ? ( 186 | 187 | ) : ( 188 | 189 | )} 190 | {displayNumbers(starCount)} 191 |
192 |
193 | 194 |

Star/Unstar the repo

195 |
196 |
197 |
198 | )} 199 | 200 | 201 | 202 | 203 | 204 | 205 |
208 | window.open(`https://github.com/${repo.full_name}/fork`) 209 | } 210 | > 211 | {displayNumbers(repo.forks_count)} 212 |
213 |
214 | 215 |

Fork the repo

216 |
217 |
218 |
219 |
220 | 221 | )} 222 |
223 | ); 224 | }; 225 | 226 | export default RepoCard; 227 | -------------------------------------------------------------------------------- /src/components/heads/TitleHead.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { AiOutlineArrowLeft } from "react-icons/ai"; 5 | 6 | interface Props { 7 | title: string; 8 | disableBackButton?: boolean; 9 | } 10 | const TitleHead = ({ title, disableBackButton = false }: Props) => { 11 | const router = useRouter(); 12 | const navigateBack = () => router.back(); 13 | 14 | return ( 15 |
16 | {!disableBackButton && ( 17 |
18 | 19 |
20 | )} 21 |

{title}

22 |
23 | ); 24 | }; 25 | 26 | export default TitleHead; 27 | -------------------------------------------------------------------------------- /src/components/lists/CommentLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { COMMENTS_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import CommentCard from "../cards/CommentCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | interface Props { 10 | username: string; 11 | } 12 | const CommentLists = ({ username }: Props) => { 13 | const { currentPage, Pagination } = usePagination(); 14 | 15 | const { isLoading: isLoadingComments, data: comments } = 16 | api.comment.otherUserComments.useQuery({ 17 | username, 18 | perPage: COMMENTS_LISTING_PER_PAGE, 19 | page: currentPage, 20 | }); 21 | const { isLoading: isLoadingProfile, data: profile } = 22 | api.github.profile.useQuery(); 23 | 24 | if (isLoadingComments || isLoadingProfile) 25 | return ( 26 | <> 27 | {[...Array(5)].map((_, idx) => ( 28 | 29 | ))} 30 | 31 | ); 32 | 33 | return ( 34 | <> 35 | {comments && comments.length === 0 && ( 36 |
37 | Uh oh.. no comment here. 38 |
39 | )} 40 | {comments && 41 | comments.length > 0 && 42 | comments.map((data) => ( 43 | 44 | ))} 45 | {comments && ( 46 | = COMMENTS_LISTING_PER_PAGE} /> 47 | )} 48 | 49 | ); 50 | }; 51 | 52 | export default CommentLists; 53 | -------------------------------------------------------------------------------- /src/components/lists/LikedPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import PostCard from "../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | interface Props { 10 | username: string; 11 | } 12 | const LikedPostLists = ({ username }: Props) => { 13 | const { currentPage, Pagination } = usePagination(); 14 | 15 | const { isLoading: isLoadingPosts, data: posts } = 16 | api.like.otherUserLikedPost.useQuery({ 17 | username, 18 | page: currentPage, 19 | perPage: POST_LISTING_PER_PAGE, 20 | }); 21 | 22 | if (isLoadingPosts) 23 | return ( 24 | <> 25 | {[...Array(3)].map((_, idx) => ( 26 | 27 | ))} 28 | 29 | ); 30 | 31 | return ( 32 | <> 33 | {posts && posts.length === 0 && ( 34 |
35 | Uh oh.. no liked post here. 36 |
37 | )} 38 | {posts && 39 | posts.length > 0 && 40 | posts.map((data) => )} 41 | {posts && = POST_LISTING_PER_PAGE} />} 42 | 43 | ); 44 | }; 45 | 46 | export default LikedPostLists; 47 | -------------------------------------------------------------------------------- /src/components/lists/MyCommentLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { COMMENTS_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import CommentCard from "../cards/CommentCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | const MyCommentLists = () => { 10 | const { currentPage, Pagination } = usePagination(); 11 | 12 | const { isLoading: isLoadingComments, data: comments } = 13 | api.comment.myComments.useQuery({ 14 | perPage: COMMENTS_LISTING_PER_PAGE, 15 | page: currentPage, 16 | }); 17 | const { isLoading: isLoadingProfile, data: profile } = 18 | api.github.profile.useQuery(); 19 | 20 | if (isLoadingComments || isLoadingProfile) 21 | return ( 22 | <> 23 | {[...Array(5)].map((_, idx) => ( 24 | 25 | ))} 26 | 27 | ); 28 | 29 | return ( 30 | <> 31 | {comments && comments.length === 0 && ( 32 |
33 | Uh oh.. no comment here. 34 |
35 | )} 36 | {comments && 37 | comments.length > 0 && 38 | comments.map((data) => ( 39 | 40 | ))} 41 | {comments && ( 42 | = COMMENTS_LISTING_PER_PAGE} /> 43 | )} 44 | 45 | ); 46 | }; 47 | 48 | export default MyCommentLists; 49 | -------------------------------------------------------------------------------- /src/components/lists/MyLikedPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import PostCard from "../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | const MyLikedPostLists = () => { 10 | const { currentPage, Pagination } = usePagination(); 11 | 12 | const { isLoading: isLoadingPosts, data: posts } = 13 | api.like.myLikedPosts.useQuery({ 14 | page: currentPage, 15 | perPage: POST_LISTING_PER_PAGE, 16 | }); 17 | 18 | if (isLoadingPosts) 19 | return ( 20 | <> 21 | {[...Array(3)].map((_, idx) => ( 22 | 23 | ))} 24 | 25 | ); 26 | 27 | return ( 28 | <> 29 | {posts && posts.length === 0 && ( 30 |
31 | Uh oh.. no liked post here. 32 |
33 | )} 34 | {posts && 35 | posts.length > 0 && 36 | posts.map((data) => )} 37 | {posts && = POST_LISTING_PER_PAGE} />} 38 | 39 | ); 40 | }; 41 | 42 | export default MyLikedPostLists; 43 | -------------------------------------------------------------------------------- /src/components/lists/MyPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import PostCard from "../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | const MyPostLists = () => { 10 | const { currentPage, Pagination } = usePagination(); 11 | 12 | const { isLoading: isLoadingPosts, data: posts } = api.post.myPosts.useQuery({ 13 | perPage: POST_LISTING_PER_PAGE, 14 | page: currentPage, 15 | }); 16 | const { isLoading: isLoadingProfile, data: profile } = 17 | api.github.profile.useQuery(); 18 | 19 | if (isLoadingPosts || isLoadingProfile) 20 | return ( 21 | <> 22 | {[...Array(3)].map((_, idx) => ( 23 | 24 | ))} 25 | 26 | ); 27 | 28 | return ( 29 | <> 30 | {posts && posts.length === 0 && ( 31 |
32 | Uh oh.. no post here. 33 |
34 | )} 35 | {posts && 36 | posts.length > 0 && 37 | posts.map((data) => ( 38 | 39 | ))} 40 | {posts && = POST_LISTING_PER_PAGE} />} 41 | 42 | ); 43 | }; 44 | 45 | export default MyPostLists; 46 | -------------------------------------------------------------------------------- /src/components/lists/MyRepoLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { REPO_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import RepoCard from "../cards/RepoCard"; 6 | import CardSkeleton from "../skeletons/CardSkeleton"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | const MyRepoLists = () => { 10 | const { currentPage, Pagination } = usePagination(); 11 | 12 | const { data: repos, isLoading } = api.github.myRepos.useQuery({ 13 | perPage: REPO_LISTING_PER_PAGE, 14 | page: currentPage, 15 | }); 16 | 17 | if (isLoading) 18 | return ( 19 | <> 20 | {[...Array(3)].map((_, idx) => ( 21 | 22 | ))} 23 | 24 | ); 25 | 26 | return ( 27 | <> 28 | {repos && repos.length === 0 && ( 29 |
No Repository Found.
30 | )} 31 | {repos && 32 | repos.length > 0 && 33 | repos.map((repo) => )} 34 | {repos && = REPO_LISTING_PER_PAGE} />} 35 | 36 | ); 37 | }; 38 | 39 | export default MyRepoLists; 40 | -------------------------------------------------------------------------------- /src/components/lists/NotificationLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { NOTIFICATION_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import NotificationCard from "../cards/NotificationCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | import { useEffect } from "react"; 9 | import { useUser } from "@clerk/nextjs"; 10 | import { getPusherClientSdk } from "@/lib/pusher/client"; 11 | import { 12 | PUSHER_NOTIFICATION_EVENT, 13 | getPusherNotificationsChannelId, 14 | } from "@/lib/pusher/shared"; 15 | 16 | const NotificationLists = () => { 17 | const { user } = useUser(); 18 | const { currentPage, Pagination } = usePagination(); 19 | 20 | const { 21 | isLoading, 22 | data: notifications, 23 | refetch, 24 | } = api.notification.getRecents.useQuery( 25 | { 26 | perPage: NOTIFICATION_LISTING_PER_PAGE, 27 | page: currentPage, 28 | }, 29 | { 30 | refetchOnWindowFocus: true, 31 | refetchOnMount: true, 32 | refetchOnReconnect: true, 33 | cacheTime: 0, 34 | staleTime: 0, 35 | } 36 | ); 37 | 38 | useEffect(() => { 39 | if (user?.username) { 40 | const username = user.username; 41 | const pusher = getPusherClientSdk(); 42 | const channel = pusher.subscribe( 43 | getPusherNotificationsChannelId(username) 44 | ); 45 | channel.bind(PUSHER_NOTIFICATION_EVENT.New, () => refetch()); 46 | return () => { 47 | pusher.unsubscribe(getPusherNotificationsChannelId(username)); 48 | channel.unbind(PUSHER_NOTIFICATION_EVENT.New); 49 | }; 50 | } 51 | // eslint-disable-next-line react-hooks/exhaustive-deps 52 | }, [user]); 53 | 54 | if (isLoading) 55 | return ( 56 | <> 57 | {[...Array(10)].map((_, idx) => ( 58 | 64 | ))} 65 | 66 | ); 67 | 68 | return ( 69 | <> 70 | {notifications && notifications.length === 0 && ( 71 |
72 | No activity here. 73 |
74 | )} 75 | {notifications && 76 | notifications.length > 0 && 77 | notifications.map((notification) => ( 78 | 79 | ))} 80 | {notifications && ( 81 | = NOTIFICATION_LISTING_PER_PAGE} 83 | /> 84 | )} 85 | 86 | ); 87 | }; 88 | 89 | export default NotificationLists; 90 | -------------------------------------------------------------------------------- /src/components/lists/PostCommentLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { COMMENTS_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CommentCard from "../cards/CommentCard"; 6 | import CardSkeleton from "../skeletons/CardSkeleton"; 7 | import CommentForm from "../CommentForm"; 8 | import usePagination from "@/hooks/usePagination"; 9 | 10 | interface Props { 11 | postId: string; 12 | } 13 | const PostCommentLists = ({ postId }: Props) => { 14 | const { currentPage, Pagination } = usePagination(); 15 | 16 | const { isLoading, data: comments } = api.comment.commentsByPostId.useQuery({ 17 | postId, 18 | page: currentPage, 19 | perPage: COMMENTS_LISTING_PER_PAGE, 20 | }); 21 | 22 | return ( 23 | <> 24 |

Replies:

25 | 26 | {isLoading && 27 | [...Array(3)].map((_, idx) => ( 28 | 29 | ))} 30 | {comments && comments.length === 0 && ( 31 |
32 | No reply. 33 |
34 | )} 35 | {comments && 36 | comments.length > 0 && 37 | comments.map((comment) => ( 38 | 43 | ))} 44 | {comments && ( 45 | = COMMENTS_LISTING_PER_PAGE} /> 46 | )} 47 | 48 | ); 49 | }; 50 | 51 | export default PostCommentLists; 52 | -------------------------------------------------------------------------------- /src/components/lists/PostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../skeletons/CardSkeleton"; 6 | import PostCard from "../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | interface Props { 10 | username: string; 11 | } 12 | const PostLists = ({ username }: Props) => { 13 | const { currentPage, Pagination } = usePagination(); 14 | 15 | const { isLoading: isLoadingPosts, data: posts } = 16 | api.post.otherUserPosts.useQuery({ 17 | username, 18 | perPage: POST_LISTING_PER_PAGE, 19 | page: currentPage, 20 | }); 21 | const { isLoading: isLoadingProfile, data: profile } = 22 | api.github.otherProfile.useQuery({ 23 | username, 24 | }); 25 | 26 | if (isLoadingPosts || isLoadingProfile) 27 | return ( 28 | <> 29 | {[...Array(3)].map((_, idx) => ( 30 | 31 | ))} 32 | 33 | ); 34 | 35 | return ( 36 | <> 37 | {posts && posts.length === 0 && ( 38 |
39 | Uh oh.. no post here. 40 |
41 | )} 42 | {posts && 43 | posts.length > 0 && 44 | posts.map((data) => ( 45 | 46 | ))} 47 | {posts && = POST_LISTING_PER_PAGE} />} 48 | 49 | ); 50 | }; 51 | 52 | export default PostLists; 53 | -------------------------------------------------------------------------------- /src/components/lists/RepoLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { REPO_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import RepoCard from "../cards/RepoCard"; 6 | import CardSkeleton from "../skeletons/CardSkeleton"; 7 | import usePagination from "@/hooks/usePagination"; 8 | 9 | interface Props { 10 | username: string; 11 | } 12 | const RepoLists = ({ username }: Props) => { 13 | const { currentPage, Pagination } = usePagination(); 14 | 15 | const { data: repos, isLoading } = api.github.otherUserRepos.useQuery({ 16 | perPage: REPO_LISTING_PER_PAGE, 17 | page: currentPage, 18 | username, 19 | }); 20 | 21 | if (isLoading) 22 | return ( 23 | <> 24 | {[...Array(3)].map((_, idx) => ( 25 | 26 | ))} 27 | 28 | ); 29 | 30 | return ( 31 | <> 32 | {repos && repos.length === 0 && ( 33 |
No Repository Found.
34 | )} 35 | {repos && 36 | repos.length > 0 && 37 | repos.map((repo) => )} 38 | {repos && = REPO_LISTING_PER_PAGE} />} 39 | 40 | ); 41 | }; 42 | 43 | export default RepoLists; 44 | -------------------------------------------------------------------------------- /src/components/lists/RepoPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import { TrimmedGitHubRepo } from "@/types/github"; 6 | import CardSkeleton from "../skeletons/CardSkeleton"; 7 | import PostCard from "../cards/PostCard"; 8 | import usePagination from "@/hooks/usePagination"; 9 | 10 | interface Props { 11 | repo: TrimmedGitHubRepo; 12 | } 13 | 14 | const RepoPostLists = ({ repo }: Props) => { 15 | const { currentPage, Pagination } = usePagination(); 16 | 17 | const { isLoading, data: posts } = api.post.repoSharedPosts.useQuery({ 18 | repoName: repo.full_name, 19 | page: currentPage, 20 | perPage: POST_LISTING_PER_PAGE, 21 | }); 22 | 23 | return ( 24 | <> 25 |

26 | Posts that shared this repo: 27 |

28 | {isLoading && 29 | [...Array(3)].map((_, idx) => ( 30 | 31 | ))} 32 | {posts && posts.length === 0 && ( 33 |
34 | No post linked to this repo. 35 |
36 | )} 37 | {posts && 38 | posts.length > 0 && 39 | posts.map((data) => ( 40 | 41 | ))} 42 | {posts && = POST_LISTING_PER_PAGE} />} 43 | 44 | ); 45 | }; 46 | 47 | export default RepoPostLists; 48 | -------------------------------------------------------------------------------- /src/components/lists/feeds/FollowingFeedLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../../skeletons/CardSkeleton"; 6 | import PostCard from "../../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | import useRefetchTimer from "@/hooks/useRefetchTimer"; 9 | import { Button } from "@/components/ui/button"; 10 | 11 | const FollowingFeedLists = () => { 12 | const { currentPage, Pagination, resetPage } = usePagination(); 13 | const { toRefetch, restartTimer } = useRefetchTimer(); 14 | 15 | const { 16 | isLoading: isLoadingPosts, 17 | isFetching: isRefetchingPosts, 18 | data: posts, 19 | refetch, 20 | } = api.post.followingFeedPosts.useQuery({ 21 | perPage: POST_LISTING_PER_PAGE, 22 | page: currentPage, 23 | }); 24 | 25 | const handleRefetch = () => { 26 | if (!toRefetch) return; 27 | resetPage(); 28 | refetch(); 29 | restartTimer(); 30 | }; 31 | 32 | if (isLoadingPosts || isRefetchingPosts) 33 | return ( 34 | <> 35 | {[...Array(10)].map((_, idx) => ( 36 | 37 | ))} 38 | 39 | ); 40 | 41 | return ( 42 | <> 43 | {posts && posts.length === 0 && ( 44 |
45 | Uh oh.. no post here. 46 |
47 | )} 48 | {toRefetch && ( 49 |
50 | 51 |
52 | )} 53 | {posts && 54 | posts.length > 0 && 55 | posts.map((data) => )} 56 | {posts && = POST_LISTING_PER_PAGE} />} 57 | 58 | ); 59 | }; 60 | 61 | export default FollowingFeedLists; 62 | -------------------------------------------------------------------------------- /src/components/lists/feeds/HotPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../../skeletons/CardSkeleton"; 6 | import PostCard from "../../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | import useRefetchTimer from "@/hooks/useRefetchTimer"; 9 | import { Button } from "@/components/ui/button"; 10 | 11 | const HotPostLists = () => { 12 | const { currentPage, Pagination, resetPage } = usePagination(); 13 | const { toRefetch, restartTimer } = useRefetchTimer(); 14 | 15 | const { 16 | isLoading: isLoadingPosts, 17 | isFetching: isRefetchingPosts, 18 | data: posts, 19 | refetch, 20 | } = api.post.hotFeedPosts.useQuery({ 21 | perPage: POST_LISTING_PER_PAGE, 22 | page: currentPage, 23 | }); 24 | 25 | const handleRefetch = () => { 26 | if (!toRefetch) return; 27 | resetPage(); 28 | refetch(); 29 | restartTimer(); 30 | }; 31 | 32 | if (isLoadingPosts || isRefetchingPosts) 33 | return ( 34 | <> 35 | {[...Array(10)].map((_, idx) => ( 36 | 37 | ))} 38 | 39 | ); 40 | 41 | return ( 42 | <> 43 | {posts && posts.length === 0 && ( 44 |
45 | Uh oh.. no post here. 46 |
47 | )} 48 | {toRefetch && ( 49 |
50 | 51 |
52 | )} 53 | {posts && 54 | posts.length > 0 && 55 | posts.map((data) => )} 56 | {posts && = POST_LISTING_PER_PAGE} />} 57 | 58 | ); 59 | }; 60 | 61 | export default HotPostLists; 62 | -------------------------------------------------------------------------------- /src/components/lists/feeds/LatestPostLists.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { POST_LISTING_PER_PAGE } from "@/constants"; 4 | import { api } from "@/lib/api/client"; 5 | import CardSkeleton from "../../skeletons/CardSkeleton"; 6 | import PostCard from "../../cards/PostCard"; 7 | import usePagination from "@/hooks/usePagination"; 8 | import useRefetchTimer from "@/hooks/useRefetchTimer"; 9 | import { Button } from "@/components/ui/button"; 10 | 11 | const LatestPostLists = () => { 12 | const { currentPage, Pagination, resetPage } = usePagination(); 13 | const { toRefetch, restartTimer } = useRefetchTimer(); 14 | 15 | const { 16 | isLoading: isLoadingPosts, 17 | isFetching: isRefetchingPosts, 18 | data: posts, 19 | refetch, 20 | } = api.post.latestFeedPosts.useQuery({ 21 | perPage: POST_LISTING_PER_PAGE, 22 | page: currentPage, 23 | }); 24 | 25 | const handleRefetch = () => { 26 | if (!toRefetch) return; 27 | resetPage(); 28 | refetch(); 29 | restartTimer(); 30 | }; 31 | 32 | if (isLoadingPosts || isRefetchingPosts) 33 | return ( 34 | <> 35 | {[...Array(10)].map((_, idx) => ( 36 | 37 | ))} 38 | 39 | ); 40 | 41 | return ( 42 | <> 43 | {posts && posts.length === 0 && ( 44 |
45 | Uh oh.. no post here. 46 |
47 | )} 48 | {toRefetch && ( 49 |
50 | 51 |
52 | )} 53 | {posts && 54 | posts.length > 0 && 55 | posts.map((data) => )} 56 | {posts && = POST_LISTING_PER_PAGE} />} 57 | 58 | ); 59 | }; 60 | 61 | export default LatestPostLists; 62 | -------------------------------------------------------------------------------- /src/components/skeletons/AvatarSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "../ui/skeleton"; 2 | 3 | const AvatarSkeleton = () => { 4 | return ( 5 |
6 | 7 | 8 |
9 | ); 10 | }; 11 | 12 | export default AvatarSkeleton; 13 | -------------------------------------------------------------------------------- /src/components/skeletons/CardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "../ui/skeleton"; 2 | import { Separator } from "../ui/separator"; 3 | import StarSkeleton from "./StarSkeleton"; 4 | import AvatarSkeleton from "./AvatarSkeleton"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | interface Props { 8 | hideCounts?: boolean; 9 | withAvatar?: boolean; 10 | border?: boolean; 11 | } 12 | const CardSkeleton = ({ 13 | hideCounts = false, 14 | withAvatar = false, 15 | border = true, 16 | }: Props) => { 17 | return ( 18 |
26 |
27 | {withAvatar ? ( 28 | 29 | ) : ( 30 | 31 | )} 32 | 33 | 34 |
35 | {!hideCounts && ( 36 | <> 37 | 38 |
39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | )} 47 |
48 | ); 49 | }; 50 | 51 | export default CardSkeleton; 52 | -------------------------------------------------------------------------------- /src/components/skeletons/StarSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "../ui/skeleton"; 2 | 3 | const StarSkeleton = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default StarSkeleton; 12 | -------------------------------------------------------------------------------- /src/components/skeletons/UserCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "../ui/skeleton"; 2 | 3 | const UserCardSkeleton = () => { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 | 11 |
12 | ); 13 | }; 14 | 15 | export default UserCardSkeleton; 16 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { buttonVariants } from "@/components/ui/button"; 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root; 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger; 12 | 13 | const AlertDialogPortal = ({ 14 | className, 15 | children, 16 | ...props 17 | }: AlertDialogPrimitive.AlertDialogPortalProps) => ( 18 | 19 |
20 | {children} 21 |
22 |
23 | ); 24 | AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName; 25 | 26 | const AlertDialogOverlay = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, children, ...props }, ref) => ( 30 | 38 | )); 39 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; 40 | 41 | const AlertDialogContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, ...props }, ref) => ( 45 | 46 | 47 | 55 | 56 | )); 57 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; 58 | 59 | const AlertDialogHeader = ({ 60 | className, 61 | ...props 62 | }: React.HTMLAttributes) => ( 63 |
70 | ); 71 | AlertDialogHeader.displayName = "AlertDialogHeader"; 72 | 73 | const AlertDialogFooter = ({ 74 | className, 75 | ...props 76 | }: React.HTMLAttributes) => ( 77 |
84 | ); 85 | AlertDialogFooter.displayName = "AlertDialogFooter"; 86 | 87 | const AlertDialogTitle = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => ( 91 | 96 | )); 97 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; 98 | 99 | const AlertDialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )); 109 | AlertDialogDescription.displayName = 110 | AlertDialogPrimitive.Description.displayName; 111 | 112 | const AlertDialogAction = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, ...props }, ref) => ( 116 | 121 | )); 122 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; 123 | 124 | const AlertDialogCancel = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, ...props }, ref) => ( 128 | 137 | )); 138 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; 139 | 140 | export { 141 | AlertDialog, 142 | AlertDialogTrigger, 143 | AlertDialogContent, 144 | AlertDialogHeader, 145 | AlertDialogFooter, 146 | AlertDialogTitle, 147 | AlertDialogDescription, 148 | AlertDialogAction, 149 | AlertDialogCancel, 150 | }; 151 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | Avatar.displayName = AvatarPrimitive.Root.displayName; 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )); 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )); 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 49 | 50 | export { Avatar, AvatarImage, AvatarFallback }; 51 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VariantProps, cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", 13 | secondary: 14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", 15 | destructive: 16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { VariantProps, cva } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-slate-700 border-input hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ); 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 | 19 | )} 20 | {currentPage > 1 && nextPage &&
{currentPage}
} 21 | {nextPage && ( 22 | 28 | )} 29 |
30 | ); 31 | 32 | return { 33 | currentPage, 34 | Pagination, 35 | resetPage, 36 | }; 37 | }; 38 | 39 | export default usePagination; 40 | -------------------------------------------------------------------------------- /src/hooks/useRefetchTimer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | const A_MINUTE_IN_MILLISECONDS = 30000; 6 | 7 | const useRefetchTimer = (time?: number) => { 8 | const duration = time ?? A_MINUTE_IN_MILLISECONDS; 9 | const [toRefetch, setToRefetch] = useState(false); 10 | 11 | useEffect(() => { 12 | let timeout: NodeJS.Timeout; 13 | if (!toRefetch) { 14 | timeout = setTimeout(() => { 15 | setToRefetch(true); 16 | }, duration); 17 | } 18 | return () => clearTimeout(timeout); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, [toRefetch]); 21 | 22 | const restartTimer = () => setToRefetch(false); 23 | 24 | return { 25 | toRefetch, 26 | restartTimer, 27 | }; 28 | }; 29 | 30 | export default useRefetchTimer; 31 | -------------------------------------------------------------------------------- /src/lib/api/client.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { httpBatchLink, loggerLink } from "@trpc/client"; 4 | import superjson from "superjson"; 5 | import { 6 | createHydrateClient, 7 | createTRPCNextBeta, 8 | } from "@/@trpc/next-layout/client"; 9 | 10 | const getBaseUrl = () => { 11 | if (typeof window !== "undefined") return ""; // browser should use relative url 12 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 13 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 14 | }; 15 | 16 | /* 17 | * Create a client that can be used in the client only 18 | */ 19 | 20 | export const api = createTRPCNextBeta({ 21 | transformer: superjson, 22 | queryClientConfig: { 23 | defaultOptions: { 24 | queries: { 25 | refetchOnWindowFocus: false, 26 | refetchInterval: false, 27 | retry: false, 28 | cacheTime: Infinity, 29 | staleTime: Infinity, 30 | }, 31 | }, 32 | }, 33 | links: [ 34 | loggerLink({ 35 | enabled: (opts) => 36 | process.env.NODE_ENV === "development" || 37 | (opts.direction === "down" && opts.result instanceof Error), 38 | }), 39 | httpBatchLink({ 40 | url: `${getBaseUrl()}/api/trpc`, 41 | }), 42 | ], 43 | }); 44 | 45 | /* 46 | * A component used to hydrate the state from server to client 47 | */ 48 | 49 | export const HydrateClient = createHydrateClient({ 50 | transformer: superjson, 51 | }); 52 | 53 | import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; 54 | import { AppRouter } from "@/server/api/root"; 55 | 56 | export type RouterOutputs = inferRouterOutputs; 57 | export type RouterInputs = inferRouterInputs; 58 | -------------------------------------------------------------------------------- /src/lib/api/server.ts: -------------------------------------------------------------------------------- 1 | import { auth as getAuth } from "@clerk/nextjs/app-beta"; 2 | import superjson from "superjson"; 3 | 4 | import "server-only"; 5 | import { createTRPCNextLayout } from "@/@trpc/next-layout/server"; 6 | import { appRouter } from "@/server/api/root"; 7 | import { createContextInner } from "@/server/api/context"; 8 | 9 | export const api = createTRPCNextLayout({ 10 | router: appRouter, 11 | transformer: superjson, 12 | createContext() { 13 | const auth = getAuth(); 14 | return createContextInner({ 15 | auth, 16 | req: null, 17 | }); 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /src/lib/pusher/client.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { env } from "@/env.mjs"; 4 | import Pusher from "pusher-js"; 5 | 6 | let pusherClientSdk: Pusher; 7 | 8 | export const getPusherClientSdk = () => { 9 | if (!pusherClientSdk) { 10 | pusherClientSdk = new Pusher(env.NEXT_PUBLIC_PUSHER_KEY, { 11 | cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER, 12 | }); 13 | } 14 | return pusherClientSdk; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/pusher/server.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | import Pusher from "pusher"; 3 | 4 | let pusherServerSdk: Pusher; 5 | 6 | export const getPusherServerSdk = () => { 7 | if (!pusherServerSdk) { 8 | pusherServerSdk = new Pusher({ 9 | appId: env.PUSHER_ID, 10 | key: env.NEXT_PUBLIC_PUSHER_KEY, 11 | secret: env.PUSHER_SECRET, 12 | cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER, 13 | useTLS: true, 14 | }); 15 | } 16 | return pusherServerSdk; 17 | }; 18 | -------------------------------------------------------------------------------- /src/lib/pusher/shared.ts: -------------------------------------------------------------------------------- 1 | export enum PUSHER_CHANNEL_BASE { 2 | Notifications = "notifications", 3 | } 4 | 5 | export enum PUSHER_NOTIFICATION_EVENT { 6 | New = "new", 7 | } 8 | 9 | export const getPusherNotificationsChannelId = (username: string) => 10 | `${PUSHER_CHANNEL_BASE.Notifications}__${username}`; 11 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse, type NextRequest } from "next/server"; 2 | import { getAuth, withClerkMiddleware } from "@clerk/nextjs/server"; 3 | 4 | const publicPaths = ["/sign-in*", "/sign-up*", "/api*"]; 5 | 6 | const isPublic = (reqPath: string) => { 7 | return publicPaths.find((publicPath) => 8 | reqPath.match(new RegExp(`^${publicPath}$`.replace("*$", "($|/)"))) 9 | ); 10 | }; 11 | 12 | export default withClerkMiddleware((request: NextRequest) => { 13 | if (isPublic(request.nextUrl.pathname)) { 14 | return NextResponse.next(); 15 | } 16 | 17 | const { userId } = getAuth(request); 18 | 19 | if (!userId) { 20 | const signInUrl = new URL("/sign-in", request.url); 21 | signInUrl.searchParams.set("redirect_url", request.url); 22 | return NextResponse.redirect(signInUrl); 23 | } 24 | 25 | return NextResponse.next(); 26 | }); 27 | 28 | // Stop Middleware running on static files and public folder 29 | export const config = { 30 | matcher: "/((?!_next/image|_next/static|favicon.ico|site.webmanifest).*)", 31 | }; -------------------------------------------------------------------------------- /src/pages/api/pusher/push-notification.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | import { getPusherServerSdk } from "@/lib/pusher/server"; 3 | import { 4 | PUSHER_NOTIFICATION_EVENT, 5 | getPusherNotificationsChannelId, 6 | } from "@/lib/pusher/shared"; 7 | import { publishNotificationSchema } from "@/validationSchemas"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | if (req.method !== "POST") { 15 | res.status(400).end(); 16 | } 17 | const apiKey = req.headers["x-api-key"]; 18 | if (apiKey !== env.PUSHER_API_KEY) { 19 | res.status(401).end(); 20 | } 21 | 22 | try { 23 | const notificationObj = publishNotificationSchema.parse( 24 | JSON.parse(req.body) 25 | ); 26 | 27 | const pusher = getPusherServerSdk(); 28 | pusher.trigger( 29 | getPusherNotificationsChannelId(notificationObj.receiverId), 30 | PUSHER_NOTIFICATION_EVENT.New, 31 | {} 32 | ); 33 | 34 | res.status(201).end(); 35 | } catch (ex) { 36 | console.error(ex); 37 | res.status(400).end(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from "next/server"; 2 | import { getAuth } from "@clerk/nextjs/server"; 3 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 4 | import { env } from "@/env.mjs"; 5 | import { createContextInner } from "@/server/api/context"; 6 | import { appRouter } from "@/server/api/root"; 7 | 8 | export default function handler(req: NextRequest) { 9 | return fetchRequestHandler({ 10 | req, 11 | endpoint: "/api/trpc", 12 | router: appRouter, 13 | createContext() { 14 | const auth = getAuth(req); 15 | return createContextInner({ 16 | req, 17 | auth, 18 | }); 19 | }, 20 | onError: 21 | env.NODE_ENV === "development" 22 | ? ({ path, error }) => { 23 | console.error( 24 | `❌ tRPC failed on ${path ?? ""}: ${error.message}` 25 | ); 26 | } 27 | : undefined, 28 | }); 29 | } 30 | 31 | export const runtime = "edge"; 32 | -------------------------------------------------------------------------------- /src/providers/ClientProviders.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { env } from "@/env.mjs"; 4 | import { ClerkProvider } from "@clerk/nextjs/app-beta/client"; 5 | import React from "react"; 6 | import { api } from "@/lib/api/client"; 7 | import { Toaster } from "@/components/ui/toaster"; 8 | import PostModalProvider from "./PostModalProvider"; 9 | 10 | export function ClientProviders({ children }: { children: React.ReactNode }) { 11 | return ( 12 | 13 | 14 | {children} 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/providers/PostModalProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PostForm from "@/components/PostForm"; 4 | import { TrimmedGitHubRepo } from "@/types/github"; 5 | import React, { createContext, useContext, useState } from "react"; 6 | 7 | interface PostModalContextValues { 8 | isOpened: boolean; 9 | repo?: TrimmedGitHubRepo; 10 | handleOpen: (repoFromProp?: TrimmedGitHubRepo) => void; 11 | handleClose: () => void; 12 | deleteRepo: () => void; 13 | } 14 | export const PostModalContext = createContext({ 15 | isOpened: false, 16 | repo: undefined, 17 | handleOpen: () => {}, 18 | handleClose: () => {}, 19 | deleteRepo: () => {}, 20 | }); 21 | 22 | export const usePostModalContext = () => useContext(PostModalContext); 23 | 24 | const PostModalProvider = ({ children }: { children: React.ReactNode }) => { 25 | const [isOpened, setIsOpened] = useState(false); 26 | const [repo, setRepo] = useState(); 27 | 28 | const handleOpen = (repoFromProp?: TrimmedGitHubRepo) => { 29 | setRepo(repoFromProp); 30 | setIsOpened(true); 31 | }; 32 | 33 | const handleClose = () => { 34 | setRepo(undefined); 35 | setIsOpened(false); 36 | }; 37 | 38 | const deleteRepo = () => { 39 | setRepo(undefined); 40 | }; 41 | 42 | return ( 43 | 52 | {children} 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default PostModalProvider; 59 | -------------------------------------------------------------------------------- /src/server/api/context.ts: -------------------------------------------------------------------------------- 1 | import type { GetServerSidePropsContext } from "next"; 2 | import type { NextRequest } from "next/server"; 3 | import type { 4 | SignedInAuthObject, 5 | SignedOutAuthObject, 6 | } from "@clerk/nextjs/dist/api"; 7 | import { getAuth } from "@clerk/nextjs/server"; 8 | import type { inferAsyncReturnType } from "@trpc/server"; 9 | import type { CreateNextContextOptions } from "@trpc/server/adapters/next"; 10 | import { db } from "../db"; 11 | 12 | type CreateContextOptions = { 13 | auth: SignedInAuthObject | SignedOutAuthObject | null; 14 | req: NextRequest | GetServerSidePropsContext["req"] | null; 15 | }; 16 | 17 | export const createContextInner = (opts: CreateContextOptions) => { 18 | return { 19 | auth: opts.auth, 20 | req: opts.req, 21 | db, 22 | }; 23 | }; 24 | 25 | export const createContext = (opts: CreateNextContextOptions) => { 26 | const auth = getAuth(opts.req); 27 | return createContextInner({ 28 | auth, 29 | req: opts.req, 30 | }); 31 | }; 32 | 33 | export type Context = inferAsyncReturnType; 34 | -------------------------------------------------------------------------------- /src/server/api/procedures.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from "@trpc/server"; 2 | import { procedure } from "./trpc"; 3 | import { clerkClient } from "@clerk/nextjs/server"; 4 | import cachedTokens from "../caches/oAuthCache"; 5 | 6 | /** 7 | * Public 8 | */ 9 | export const publicProcedure = procedure; 10 | 11 | /** 12 | * For logged-in user 13 | */ 14 | export const userProtectedProcedure = publicProcedure.use( 15 | async ({ ctx, next }) => { 16 | if (!ctx.auth?.userId) { 17 | throw new TRPCError({ 18 | code: "UNAUTHORIZED", 19 | message: "Not authenticated", 20 | }); 21 | } 22 | 23 | return next({ 24 | ctx: { 25 | ...ctx, 26 | auth: ctx.auth, 27 | }, 28 | }); 29 | } 30 | ); 31 | 32 | /** 33 | * For logged-in user with GitHub OAuth Access Token 34 | */ 35 | export const gitHubProtectedProcedure = userProtectedProcedure.use( 36 | async ({ ctx, next }) => { 37 | const { 38 | auth: { userId }, 39 | } = ctx; 40 | 41 | let token: string; 42 | 43 | /** 44 | * Strategy: Cache OAuth Tokens for 30 seconds to avoid making too many request to Clerk 45 | */ 46 | const tokenFromCache = cachedTokens.getToken(userId); 47 | if (tokenFromCache) { 48 | token = tokenFromCache; 49 | } else { 50 | const oAuthTokens = await clerkClient.users.getUserOauthAccessToken( 51 | userId, 52 | "oauth_github" 53 | ); 54 | 55 | if (!oAuthTokens[0]?.token) { 56 | throw new TRPCError({ 57 | code: "UNAUTHORIZED", 58 | message: "Failed to retrieve OAuth tokens from Clerk", 59 | }); 60 | } 61 | 62 | token = oAuthTokens[0].token; 63 | 64 | cachedTokens.setToken(userId, { 65 | token, 66 | lastFetched: new Date(), 67 | }); 68 | } 69 | 70 | return next({ 71 | ctx: { 72 | ...ctx, 73 | oAuth: { 74 | token, 75 | }, 76 | }, 77 | }); 78 | } 79 | ); 80 | -------------------------------------------------------------------------------- /src/server/api/root.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter } from "@/server/api/trpc"; 2 | import { githubRouter } from "./routers/github"; 3 | import { postRouter } from "./routers/post"; 4 | import { commentRouter } from "./routers/comments"; 5 | import { likeRouter } from "./routers/like"; 6 | import { notificationRouter } from "./routers/notifications"; 7 | 8 | export const appRouter = createTRPCRouter({ 9 | github: githubRouter, 10 | post: postRouter, 11 | comment: commentRouter, 12 | like: likeRouter, 13 | notification: notificationRouter, 14 | }); 15 | 16 | // export type definition of API 17 | export type AppRouter = typeof appRouter; 18 | -------------------------------------------------------------------------------- /src/server/api/routers/comments.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import { userProtectedProcedure } from "../procedures"; 3 | import { createTRPCRouter } from "../trpc"; 4 | import { 5 | createCommentSchema, 6 | idSchema, 7 | paginationSchema, 8 | } from "@/validationSchemas"; 9 | import { comments } from "@/server/db/schema/comments"; 10 | import { and, desc, eq } from "drizzle-orm"; 11 | import { posts } from "@/server/db/schema/posts"; 12 | import { TRPCError } from "@trpc/server"; 13 | import { v4 } from "uuid"; 14 | import { getUsernameFromClerkOrCached } from "@/server/caches/usernameCache"; 15 | import { postNotification } from "@/server/helpers/notifications"; 16 | 17 | export const commentRouter = createTRPCRouter({ 18 | commentById: userProtectedProcedure 19 | .input(idSchema) 20 | .query(async ({ ctx, input }) => { 21 | const { db } = ctx; 22 | 23 | const foundComment = ( 24 | await db.select().from(comments).where(eq(comments.id, input.id)) 25 | )[0]; 26 | 27 | if (!foundComment) return; 28 | return foundComment; 29 | }), 30 | 31 | commentsByPostId: userProtectedProcedure 32 | .input( 33 | z 34 | .object({ 35 | postId: z.string().min(1), 36 | }) 37 | .merge(paginationSchema) 38 | ) 39 | .query(async ({ ctx, input }) => { 40 | const { db } = ctx; 41 | const { postId, page, perPage } = input; 42 | 43 | const commentLists = await db 44 | .select() 45 | .from(comments) 46 | .where(eq(comments.postId, postId)) 47 | .orderBy(desc(comments.createdAt)) 48 | .limit(perPage) 49 | .offset((page - 1) * perPage); 50 | 51 | return commentLists; 52 | }), 53 | 54 | myComments: userProtectedProcedure 55 | .input(paginationSchema) 56 | .query(async ({ ctx, input }) => { 57 | const { 58 | db, 59 | auth: { userId }, 60 | } = ctx; 61 | const { page, perPage } = input; 62 | const username = await getUsernameFromClerkOrCached(userId); 63 | 64 | const commentLists = await db 65 | .select() 66 | .from(comments) 67 | .where(eq(comments.ownerId, username)) 68 | .orderBy(desc(comments.createdAt)) 69 | .limit(perPage) 70 | .offset((page - 1) * perPage); 71 | 72 | return commentLists; 73 | }), 74 | 75 | otherUserComments: userProtectedProcedure 76 | .input( 77 | z 78 | .object({ 79 | username: z.string().min(1), 80 | }) 81 | .merge(paginationSchema) 82 | ) 83 | .query(async ({ ctx, input }) => { 84 | const { db } = ctx; 85 | const { page, perPage, username } = input; 86 | 87 | const commentLists = await db 88 | .select() 89 | .from(comments) 90 | .where(eq(comments.ownerId, username)) 91 | .orderBy(desc(comments.createdAt)) 92 | .limit(perPage) 93 | .offset((page - 1) * perPage); 94 | 95 | return commentLists; 96 | }), 97 | 98 | create: userProtectedProcedure 99 | .input(createCommentSchema) 100 | .mutation(async ({ ctx, input }) => { 101 | const { 102 | db, 103 | auth: { userId }, 104 | } = ctx; 105 | const { content, postId } = input; 106 | const username = await getUsernameFromClerkOrCached(userId); 107 | 108 | const postReference = ( 109 | await db 110 | .select({ id: posts.id, ownerId: posts.ownerId }) 111 | .from(posts) 112 | .where(eq(posts.id, postId)) 113 | )[0]; 114 | 115 | if (!postReference) { 116 | throw new TRPCError({ 117 | code: "BAD_REQUEST", 118 | message: "Post doesn't exist", 119 | }); 120 | } 121 | 122 | const commentId = v4(); 123 | await db 124 | .insert(comments) 125 | .values({ 126 | id: commentId, 127 | ownerId: username, 128 | content, 129 | postId: postReference.id, 130 | }) 131 | .catch((err) => { 132 | console.error(err); 133 | throw new TRPCError({ 134 | code: "INTERNAL_SERVER_ERROR", 135 | message: 136 | "There's an error occured when trying to create the comment.", 137 | }); 138 | }); 139 | 140 | // notifications adding comments 141 | await postNotification(db, { 142 | originId: username, 143 | receiverId: postReference.ownerId, 144 | postAction: "comment", 145 | postId: postReference.id, 146 | commentId: commentId, 147 | }); 148 | }), 149 | 150 | deleteById: userProtectedProcedure 151 | .input(idSchema) 152 | .mutation(async ({ ctx, input }) => { 153 | const { 154 | db, 155 | auth: { userId }, 156 | } = ctx; 157 | const username = await getUsernameFromClerkOrCached(userId); 158 | 159 | await db 160 | .delete(comments) 161 | .where(and(eq(comments.ownerId, username), eq(comments.id, input.id))) 162 | .catch((err) => { 163 | console.error(err); 164 | throw new TRPCError({ 165 | code: "INTERNAL_SERVER_ERROR", 166 | message: 167 | "There's an error occured when trying to delete the comment.", 168 | }); 169 | }); 170 | }), 171 | }); 172 | -------------------------------------------------------------------------------- /src/server/api/routers/github.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCRouter } from "../trpc"; 2 | import { z } from "zod"; 3 | import { 4 | trimGitHubProfileData, 5 | trimGitHubRepoData, 6 | } from "@/server/helpers/trimGitHubData"; 7 | import { gitHubProtectedProcedure } from "../procedures"; 8 | import githubApi from "@/server/helpers/githubApi"; 9 | import { githubRepoSchema, paginationSchema } from "@/validationSchemas"; 10 | import { postNotification } from "@/server/helpers/notifications"; 11 | import { getUsernameFromClerkOrCached } from "@/server/caches/usernameCache"; 12 | import { deleteFollowingsUsernameListsCache } from "@/server/caches/followingsCache"; 13 | 14 | export const githubRouter = createTRPCRouter({ 15 | profile: gitHubProtectedProcedure.query(async ({ ctx }) => { 16 | const { 17 | auth: { userId }, 18 | oAuth: { token }, 19 | } = ctx; 20 | const username = await getUsernameFromClerkOrCached(userId); 21 | const profile = await githubApi.getUserProfile(token, username); 22 | if (!profile) return; 23 | return trimGitHubProfileData(profile); 24 | }), 25 | 26 | otherProfile: gitHubProtectedProcedure 27 | .input( 28 | z.object({ 29 | username: z.string().min(1), 30 | }) 31 | ) 32 | .query(async ({ ctx, input }) => { 33 | const { 34 | oAuth: { token }, 35 | } = ctx; 36 | const { username } = input; 37 | const profile = await githubApi.getUserProfile(token, username); 38 | if (!profile) return; 39 | return trimGitHubProfileData(profile); 40 | }), 41 | 42 | followers: gitHubProtectedProcedure 43 | .input( 44 | z 45 | .object({ 46 | username: z.string(), 47 | }) 48 | .merge(paginationSchema) 49 | ) 50 | .query(async ({ ctx, input }) => { 51 | const { 52 | oAuth: { token }, 53 | } = ctx; 54 | const { page, perPage, username } = input; 55 | 56 | const profiles = await githubApi.getFollowerLists( 57 | token, 58 | username, 59 | page, 60 | perPage 61 | ); 62 | return profiles.map(trimGitHubProfileData); 63 | }), 64 | 65 | following: gitHubProtectedProcedure 66 | .input( 67 | z 68 | .object({ 69 | username: z.string(), 70 | }) 71 | .merge(paginationSchema) 72 | ) 73 | .query(async ({ ctx, input }) => { 74 | const { 75 | oAuth: { token }, 76 | } = ctx; 77 | const { page, perPage, username } = input; 78 | 79 | const profiles = await githubApi.getFollowingLists( 80 | token, 81 | username, 82 | page, 83 | perPage 84 | ); 85 | return profiles.map(trimGitHubProfileData); 86 | }), 87 | 88 | hasFollowedTheUser: gitHubProtectedProcedure 89 | .input( 90 | z.object({ 91 | username: z.string().min(1), 92 | }) 93 | ) 94 | .query(async ({ ctx, input }) => { 95 | const { 96 | oAuth: { token }, 97 | } = ctx; 98 | const { username } = input; 99 | return await githubApi.amIFollowingTheUser(token, username); 100 | }), 101 | 102 | followAction: gitHubProtectedProcedure 103 | .input( 104 | z.object({ 105 | username: z.string().min(1), 106 | action: z.enum(["follow", "unfollow"]), 107 | }) 108 | ) 109 | .mutation(async ({ ctx, input }) => { 110 | const { 111 | db, 112 | auth: { userId }, 113 | oAuth: { token }, 114 | } = ctx; 115 | const { username, action } = input; 116 | 117 | const isRequestSucceed = await githubApi.followAction( 118 | token, 119 | username, 120 | action 121 | ); 122 | 123 | // delete cache 124 | if (isRequestSucceed) deleteFollowingsUsernameListsCache(userId); 125 | 126 | // notifications 127 | if (action === "follow" && isRequestSucceed) { 128 | const originId = await getUsernameFromClerkOrCached(userId); 129 | 130 | await postNotification(db, { 131 | originId, 132 | receiverId: username, 133 | githubAction: "follow", 134 | }); 135 | } 136 | 137 | const successMessage = 138 | action === "unfollow" 139 | ? "Successfully unfollowed the user" 140 | : "Successfully followed the user"; 141 | return { 142 | success: isRequestSucceed, 143 | message: isRequestSucceed ? successMessage : "There's an error occured", 144 | }; 145 | }), 146 | 147 | hasStarredTheRepo: gitHubProtectedProcedure 148 | .input(githubRepoSchema) 149 | .query(async ({ ctx, input }) => { 150 | const { 151 | oAuth: { token }, 152 | } = ctx; 153 | return await githubApi.hasIStarredTheRepo(token, input.repoName); 154 | }), 155 | 156 | getARepo: gitHubProtectedProcedure 157 | .input(githubRepoSchema) 158 | .query(async ({ ctx, input }) => { 159 | const { 160 | oAuth: { token }, 161 | } = ctx; 162 | const repo = await githubApi.getARepo(token, input.repoName); 163 | if (!repo) return; 164 | return trimGitHubRepoData(repo); 165 | }), 166 | 167 | myRepos: gitHubProtectedProcedure 168 | .input(paginationSchema) 169 | .query(async ({ ctx, input }) => { 170 | const { 171 | oAuth: { token }, 172 | } = ctx; 173 | const { page, perPage } = input; 174 | const repos = await githubApi.myRepoLists(token, page, perPage); 175 | return repos.map(trimGitHubRepoData); 176 | }), 177 | 178 | otherUserRepos: gitHubProtectedProcedure 179 | .input( 180 | z 181 | .object({ 182 | username: z.string().min(1), 183 | }) 184 | .merge(paginationSchema) 185 | ) 186 | .query(async ({ ctx, input }) => { 187 | const { 188 | oAuth: { token }, 189 | } = ctx; 190 | const { page, perPage, username } = input; 191 | const repos = await githubApi.otherUserRepoLists( 192 | token, 193 | username, 194 | page, 195 | perPage 196 | ); 197 | return repos.map(trimGitHubRepoData); 198 | }), 199 | 200 | starAction: gitHubProtectedProcedure 201 | .input( 202 | z 203 | .object({ 204 | action: z.enum(["star", "unstar"]), 205 | }) 206 | .merge(githubRepoSchema) 207 | ) 208 | .mutation(async ({ ctx, input }) => { 209 | const { 210 | db, 211 | auth: { userId }, 212 | oAuth: { token }, 213 | } = ctx; 214 | const { repoName, action } = input; 215 | 216 | const isRequestSucceed = await githubApi.starAction( 217 | token, 218 | repoName, 219 | action 220 | ); 221 | 222 | // notifications 223 | if (action === "star" && isRequestSucceed) { 224 | const receiverId = repoName.split("/")[0]; 225 | const originId = await getUsernameFromClerkOrCached(userId); 226 | 227 | await postNotification(db, { 228 | originId, 229 | receiverId, 230 | githubAction: "star", 231 | repoName, 232 | }); 233 | } 234 | 235 | const successMessage = 236 | action === "unstar" 237 | ? "Successfully unstarred the repository" 238 | : "Successfully starred the repository"; 239 | return { 240 | success: isRequestSucceed, 241 | message: isRequestSucceed ? successMessage : "There's an error occured", 242 | }; 243 | }), 244 | }); 245 | -------------------------------------------------------------------------------- /src/server/api/routers/like.ts: -------------------------------------------------------------------------------- 1 | import { userProtectedProcedure } from "../procedures"; 2 | import { createTRPCRouter } from "../trpc"; 3 | import { likeActionSchema, paginationSchema } from "@/validationSchemas"; 4 | import { and, desc, eq, inArray } from "drizzle-orm"; 5 | import { Post, posts } from "@/server/db/schema/posts"; 6 | import { TRPCError } from "@trpc/server"; 7 | import { v4 } from "uuid"; 8 | import { likes } from "@/server/db/schema/likes"; 9 | import { getUsernameFromClerkOrCached } from "@/server/caches/usernameCache"; 10 | import { z } from "zod"; 11 | import { getPostsWithCommentsCountAndLikesCountQuery } from "@/server/helpers/drizzleQueries"; 12 | import { postNotification } from "@/server/helpers/notifications"; 13 | 14 | export const likeRouter = createTRPCRouter({ 15 | myLikedPosts: userProtectedProcedure 16 | .input(paginationSchema) 17 | .query(async ({ ctx, input }) => { 18 | const { 19 | db, 20 | auth: { userId }, 21 | } = ctx; 22 | const { page, perPage } = input; 23 | 24 | const username = await getUsernameFromClerkOrCached(userId); 25 | 26 | const likedPosts = await db 27 | .select({ 28 | id: posts.id, 29 | }) 30 | .from(likes) 31 | .where(eq(likes.ownerId, username)) 32 | .innerJoin(posts, eq(posts.id, likes.postId)) 33 | .orderBy(desc(likes.createdAt)) 34 | .limit(perPage) 35 | .offset((page - 1) * perPage); 36 | 37 | if (likedPosts.length === 0) return []; 38 | 39 | const likedPostsWithCommentsAndLikes = 40 | await getPostsWithCommentsCountAndLikesCountQuery(db).where( 41 | inArray( 42 | posts.id, 43 | likedPosts.map((post) => post.id) 44 | ) 45 | ); 46 | 47 | const mapping = new Map< 48 | string, 49 | { 50 | post: Post; 51 | commentsCount: number; 52 | likesCount: number; 53 | } 54 | >(); 55 | for (const data of likedPostsWithCommentsAndLikes) { 56 | mapping.set(data.post.id, data); 57 | } 58 | 59 | return likedPosts.map(({ id }) => mapping.get(id)!); 60 | }), 61 | 62 | otherUserLikedPost: userProtectedProcedure 63 | .input( 64 | z 65 | .object({ 66 | username: z.string().min(1), 67 | }) 68 | .merge(paginationSchema) 69 | ) 70 | .query(async ({ ctx, input }) => { 71 | const { db } = ctx; 72 | const { page, perPage, username } = input; 73 | 74 | const likedPosts = await db 75 | .select({ 76 | id: posts.id, 77 | }) 78 | .from(likes) 79 | .where(eq(likes.ownerId, username)) 80 | .innerJoin(posts, eq(posts.id, likes.postId)) 81 | .orderBy(desc(likes.createdAt)) 82 | .limit(perPage) 83 | .offset((page - 1) * perPage); 84 | 85 | if (likedPosts.length === 0) return []; 86 | 87 | const likedPostsWithCommentsAndLikes = 88 | await getPostsWithCommentsCountAndLikesCountQuery(db).where( 89 | inArray( 90 | posts.id, 91 | likedPosts.map((post) => post.id) 92 | ) 93 | ); 94 | 95 | return likedPostsWithCommentsAndLikes; 96 | }), 97 | 98 | likeActionByPostId: userProtectedProcedure 99 | .input(likeActionSchema) 100 | .mutation(async ({ ctx, input }) => { 101 | const { 102 | db, 103 | auth: { userId }, 104 | } = ctx; 105 | const { action, postId } = input; 106 | const username = await getUsernameFromClerkOrCached(userId); 107 | 108 | const postReference = ( 109 | await db 110 | .select({ id: posts.id, ownerId: posts.ownerId }) 111 | .from(posts) 112 | .where(eq(posts.id, postId)) 113 | )[0]; 114 | 115 | if (!postReference) { 116 | throw new TRPCError({ 117 | code: "BAD_REQUEST", 118 | message: "Post doesn't exist", 119 | }); 120 | } 121 | 122 | if (action === "unlike") { 123 | await db 124 | .delete(likes) 125 | .where( 126 | and(eq(likes.postId, postReference.id), eq(likes.ownerId, username)) 127 | ) 128 | .catch((err) => { 129 | console.error(err); 130 | throw new TRPCError({ 131 | code: "INTERNAL_SERVER_ERROR", 132 | message: 133 | "There's an error occured when trying to unlike the post.", 134 | }); 135 | }); 136 | } else { 137 | await db 138 | .insert(likes) 139 | .values({ 140 | id: v4(), 141 | postId: postReference.id, 142 | ownerId: username, 143 | }) 144 | .catch((err) => { 145 | console.error(err); 146 | throw new TRPCError({ 147 | code: "INTERNAL_SERVER_ERROR", 148 | message: "There's an error occured when trying to like the post.", 149 | }); 150 | }); 151 | // notifications for liking posts 152 | await postNotification(db, { 153 | originId: username, 154 | receiverId: postReference.ownerId, 155 | postAction: "like", 156 | postId: postReference.id, 157 | }); 158 | } 159 | }), 160 | 161 | hasLikedThePost: userProtectedProcedure 162 | .input( 163 | z.object({ 164 | postId: z.string().min(1), 165 | }) 166 | ) 167 | .query(async ({ ctx, input }) => { 168 | const { 169 | db, 170 | auth: { userId }, 171 | } = ctx; 172 | const username = await getUsernameFromClerkOrCached(userId); 173 | 174 | const likeFound = ( 175 | await db 176 | .select({ id: likes.id }) 177 | .from(likes) 178 | .where( 179 | and(eq(likes.postId, input.postId), eq(likes.ownerId, username)) 180 | ) 181 | )[0]; 182 | 183 | return !!likeFound; 184 | }), 185 | }); 186 | -------------------------------------------------------------------------------- /src/server/api/routers/notifications.ts: -------------------------------------------------------------------------------- 1 | import { paginationSchema } from "@/validationSchemas"; 2 | import { userProtectedProcedure } from "../procedures"; 3 | import { createTRPCRouter } from "../trpc"; 4 | import { notifications } from "@/server/db/schema/notifications"; 5 | import { desc, eq } from "drizzle-orm"; 6 | import { getUsernameFromClerkOrCached } from "@/server/caches/usernameCache"; 7 | 8 | export const notificationRouter = createTRPCRouter({ 9 | getRecents: userProtectedProcedure 10 | .input(paginationSchema) 11 | .query(async ({ ctx, input }) => { 12 | const { 13 | db, 14 | auth: { userId }, 15 | } = ctx; 16 | const { page, perPage } = input; 17 | const username = await getUsernameFromClerkOrCached(userId); 18 | 19 | const notificationLists = await db 20 | .select() 21 | .from(notifications) 22 | .where(eq(notifications.receiverId, username)) 23 | .orderBy(desc(notifications.createdAt)) 24 | .offset((page - 1) * perPage) 25 | .limit(perPage); 26 | 27 | return notificationLists; 28 | }), 29 | }); 30 | -------------------------------------------------------------------------------- /src/server/api/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | import superjson from "superjson"; 3 | import { ZodError } from "zod"; 4 | 5 | import { type Context } from "./context"; 6 | 7 | const t = initTRPC.context().create({ 8 | transformer: superjson, 9 | errorFormatter({ shape, error }) { 10 | return { 11 | ...shape, 12 | data: { 13 | ...shape.data, 14 | zodError: 15 | error.cause instanceof ZodError ? error.cause.flatten() : null, 16 | }, 17 | }; 18 | }, 19 | }); 20 | 21 | export const createTRPCRouter = t.router; 22 | export const procedure = t.procedure; 23 | -------------------------------------------------------------------------------- /src/server/caches/followingsCache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * WHY WE CACHED THIS? 3 | * - For getting feed posts, we need the all follower's username from github 4 | * - And github limits max = 100 followers per requests 5 | * - The github requests gonna be heavy if the user has a lot of followings (1 requests per 100 followers) 6 | */ 7 | 8 | import { MAX_FOLLOWING_USERNAME_LISTS_CACHE_LIFE_IN_SECONDS } from "@/constants"; 9 | import { getSecondsDifferenceFromNow } from "../helpers/getMinuteDiff"; 10 | import githubApi from "../helpers/githubApi"; 11 | import { getUsernameFromClerkOrCached } from "./usernameCache"; 12 | 13 | const userNameToFollowingUsernameListsCache = new Map< 14 | string, 15 | { 16 | followingUsernames: string[]; 17 | lastFetched: Date; 18 | } 19 | >(); 20 | 21 | export const getFollowingUsernameFromGitHubOrCached = async ( 22 | token: string, 23 | userId: string 24 | ) => { 25 | const MAX_PER_PAGE = 100; 26 | 27 | const cachedObj = userNameToFollowingUsernameListsCache.get(userId); 28 | if ( 29 | cachedObj && 30 | getSecondsDifferenceFromNow(cachedObj.lastFetched) < 31 | MAX_FOLLOWING_USERNAME_LISTS_CACHE_LIFE_IN_SECONDS 32 | ) { 33 | return cachedObj.followingUsernames; 34 | } 35 | 36 | const username = await getUsernameFromClerkOrCached(userId); 37 | 38 | const profile = await githubApi.getUserProfile(token, username); 39 | if (!profile) return []; 40 | 41 | const followingCount = profile.following; 42 | const pagesNeeded = Math.ceil(followingCount / MAX_PER_PAGE); 43 | 44 | const followingUsernames = ( 45 | await Promise.all( 46 | [...Array(pagesNeeded)].map((_, idx) => 47 | githubApi.getFollowingLists(token, username, idx + 1, MAX_PER_PAGE) 48 | ) 49 | ) 50 | ) 51 | .flat() 52 | .map((profile) => profile.login); 53 | 54 | userNameToFollowingUsernameListsCache.set(userId, { 55 | followingUsernames, 56 | lastFetched: new Date(), 57 | }); 58 | return followingUsernames; 59 | }; 60 | 61 | export const deleteFollowingsUsernameListsCache = (userId: string) => 62 | userNameToFollowingUsernameListsCache.delete(userId); 63 | -------------------------------------------------------------------------------- /src/server/caches/oAuthCache.ts: -------------------------------------------------------------------------------- 1 | import { MAX_TOKEN_LIFE_IN_SECONDS } from "@/constants"; 2 | import { getSecondsDifferenceFromNow } from "../helpers/getMinuteDiff"; 3 | 4 | /** 5 | * WHY WE CACHED THIS? 6 | * - For every requests that requires GitHub OAuth Access Token, we need to make a call to Clerk to get it 7 | * - There's some pages that making multiple parallel calls (that requires OAuth Access Token) to the server 8 | * - Caching it can minimise the requests to Clerk 9 | * - We keep the cached tokens short lived (30 seconds) 10 | */ 11 | 12 | interface CachedToken { 13 | token: string; 14 | lastFetched: Date; 15 | } 16 | 17 | // in-memory cache 18 | const oAuthCache = new Map(); 19 | 20 | const getToken = (userId: string) => { 21 | const obj = oAuthCache.get(userId); 22 | if (!obj) return; 23 | 24 | if ( 25 | getSecondsDifferenceFromNow(obj.lastFetched) >= MAX_TOKEN_LIFE_IN_SECONDS || 26 | !obj.token 27 | ) { 28 | oAuthCache.delete(userId); 29 | return; 30 | } 31 | return obj.token; 32 | }; 33 | 34 | const setToken = (userId: string, obj: CachedToken) => 35 | oAuthCache.set(userId, obj); 36 | 37 | const cachedTokens = { 38 | getToken, 39 | setToken, 40 | }; 41 | 42 | export default cachedTokens; 43 | -------------------------------------------------------------------------------- /src/server/caches/usernameCache.ts: -------------------------------------------------------------------------------- 1 | import { MAX_USERNAME_LIFE_IN_SECONDS } from "@/constants"; 2 | import { getSecondsDifferenceFromNow } from "../helpers/getMinuteDiff"; 3 | import { getUsernameFromClerkOrThrow } from "../helpers/clerk"; 4 | 5 | /** 6 | * WHY WE CACHED USERNAME? 7 | * - For every requests that requires username, we need to make a call to Clerk to get it 8 | * - Requests with paginations === Too many requests to Clerk 9 | * - Username mostly not changed, so we better cache it 10 | */ 11 | 12 | interface CachedUsername { 13 | username: string; 14 | lastFetched: Date; 15 | } 16 | 17 | // in-memory cache 18 | const usernameCache = new Map(); 19 | 20 | export const getUsernameFromClerkOrCached = async (userId: string) => { 21 | const obj = usernameCache.get(userId); 22 | 23 | let username: string; 24 | if ( 25 | !obj || 26 | getSecondsDifferenceFromNow(obj.lastFetched) >= 27 | MAX_USERNAME_LIFE_IN_SECONDS || 28 | !obj.username 29 | ) { 30 | username = await getUsernameFromClerkOrThrow(userId); 31 | usernameCache.set(userId, { 32 | username, 33 | lastFetched: new Date(), 34 | }); 35 | } else { 36 | username = obj.username; 37 | } 38 | return username; 39 | }; 40 | -------------------------------------------------------------------------------- /src/server/db/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | import { neon } from "@neondatabase/serverless"; 3 | import { drizzle } from "drizzle-orm/neon-http"; 4 | 5 | const sql = neon(env.NEON_DB_URL); 6 | export const db = drizzle(sql); 7 | -------------------------------------------------------------------------------- /src/server/db/migrate.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/mysql2"; 2 | import { drizzle as neonDrizzle } from "drizzle-orm/neon-http"; 3 | import { migrate } from "drizzle-orm/neon-http/migrator"; 4 | import mysql from "mysql2/promise"; 5 | import { neon } from "@neondatabase/serverless"; 6 | // import { posts } from "./schem/posts"; 7 | import { posts as postsPg } from "./schema/posts"; 8 | // import { comments } from "./schem/comments"; 9 | import { comments as commentsPg } from "./schema/comments"; 10 | // import { likes } from "./schem/likes"; 11 | import { likes as likesPg } from "./schema/likes"; 12 | // import { notifications } from "./schem/notifications"; 13 | import { notifications as notificationsPg } from "./schema/notifications"; 14 | 15 | const runMigrate = async () => { 16 | console.log("⏳ Running migrations..."); 17 | const start = Date.now(); 18 | 19 | const neonSql = neon(process.env.NEON_DB_URL!); 20 | const neonDb = neonDrizzle(neonSql); 21 | 22 | await migrate(neonDb, { 23 | migrationsFolder: "src/server/db/migrations", 24 | }); 25 | 26 | // const connection2 = await mysql.createConnection({ 27 | // host: "", 28 | // user: "", 29 | // database: "", 30 | // password: "", 31 | // port: 0, 32 | // }); 33 | // const db = drizzle(connection2); 34 | 35 | // const allPosts = await db.select().from(posts); 36 | // const postsRes = await neonDb.insert(postsPg).values(allPosts); 37 | // console.log("posts migrated", { postsRes }); 38 | 39 | // const allComments = await db.select().from(comments); 40 | // const commentsRes = await neonDb.insert(commentsPg).values(allComments); 41 | // console.log("comments migrated", { commentsRes }); 42 | 43 | // const allLikes = await db.select().from(likes); 44 | // const likesRes = await neonDb.insert(likesPg).values(allLikes); 45 | // console.log("likes migrated", { likesRes }); 46 | 47 | // const allNotifs = await db.select().from(notifications); 48 | // const notifsRes = await neonDb.insert(notificationsPg).values(allNotifs); 49 | // console.log("notifications migrated", { notifsRes }); 50 | 51 | const end = Date.now(); 52 | console.log("✅ Migrations completed in", end - start, "ms"); 53 | process.exit(0); 54 | }; 55 | 56 | runMigrate().catch((err) => { 57 | console.error("❌ Migration failed"); 58 | console.error(err); 59 | process.exit(1); 60 | }); 61 | -------------------------------------------------------------------------------- /src/server/db/migrations/0000_lame_silver_centurion.sql: -------------------------------------------------------------------------------- 1 | DO $$ BEGIN 2 | CREATE TYPE "github_action" AS ENUM('follow', 'star', 'share'); 3 | EXCEPTION 4 | WHEN duplicate_object THEN null; 5 | END $$; 6 | --> statement-breakpoint 7 | DO $$ BEGIN 8 | CREATE TYPE "post_action" AS ENUM('comment', 'like'); 9 | EXCEPTION 10 | WHEN duplicate_object THEN null; 11 | END $$; 12 | --> statement-breakpoint 13 | CREATE TABLE IF NOT EXISTS "comments" ( 14 | "id" varchar(191) PRIMARY KEY NOT NULL, 15 | "owner_id" varchar(191) NOT NULL, 16 | "content" text NOT NULL, 17 | "post_id" varchar(191) NOT NULL, 18 | "created_at" timestamp DEFAULT now() NOT NULL 19 | ); 20 | --> statement-breakpoint 21 | CREATE TABLE IF NOT EXISTS "likes" ( 22 | "id" varchar(191) PRIMARY KEY NOT NULL, 23 | "owner_id" varchar(191) NOT NULL, 24 | "post_id" varchar(191) NOT NULL, 25 | "created_at" timestamp DEFAULT now() NOT NULL 26 | ); 27 | --> statement-breakpoint 28 | CREATE TABLE IF NOT EXISTS "notifications" ( 29 | "id" varchar(191) PRIMARY KEY NOT NULL, 30 | "github_action" "github_action", 31 | "repo_name" varchar(256), 32 | "post_action" "post_action", 33 | "post_id" varchar(191), 34 | "comment_id" varchar(191), 35 | "origin_id" varchar(191) NOT NULL, 36 | "receiver_id" varchar(191) NOT NULL, 37 | "created_at" timestamp DEFAULT now() NOT NULL 38 | ); 39 | --> statement-breakpoint 40 | CREATE TABLE IF NOT EXISTS "posts" ( 41 | "id" varchar(191) PRIMARY KEY NOT NULL, 42 | "owner_id" varchar(191) NOT NULL, 43 | "content" text NOT NULL, 44 | "repo_shared" varchar(256), 45 | "created_at" timestamp DEFAULT now() NOT NULL 46 | ); 47 | --> statement-breakpoint 48 | CREATE INDEX IF NOT EXISTS "comments_owner_id_idx" ON "comments" ("owner_id");--> statement-breakpoint 49 | CREATE INDEX IF NOT EXISTS "likes_owner_id_idx" ON "likes" ("owner_id");--> statement-breakpoint 50 | CREATE UNIQUE INDEX IF NOT EXISTS "likes_unique_idx" ON "likes" ("post_id","owner_id");--> statement-breakpoint 51 | CREATE INDEX IF NOT EXISTS "notifications_receiver_id_idx" ON "notifications" ("receiver_id");--> statement-breakpoint 52 | CREATE INDEX IF NOT EXISTS "posts_owner_id_idx" ON "posts" ("owner_id");--> statement-breakpoint 53 | CREATE INDEX IF NOT EXISTS "posts_repo_shared_idx" ON "posts" ("repo_shared");--> statement-breakpoint 54 | DO $$ BEGIN 55 | ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE no action ON UPDATE no action; 56 | EXCEPTION 57 | WHEN duplicate_object THEN null; 58 | END $$; 59 | --> statement-breakpoint 60 | DO $$ BEGIN 61 | ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE no action ON UPDATE no action; 62 | EXCEPTION 63 | WHEN duplicate_object THEN null; 64 | END $$; 65 | --> statement-breakpoint 66 | DO $$ BEGIN 67 | ALTER TABLE "notifications" ADD CONSTRAINT "notifications_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE no action ON UPDATE no action; 68 | EXCEPTION 69 | WHEN duplicate_object THEN null; 70 | END $$; 71 | --> statement-breakpoint 72 | DO $$ BEGIN 73 | ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "comments"("id") ON DELETE no action ON UPDATE no action; 74 | EXCEPTION 75 | WHEN duplicate_object THEN null; 76 | END $$; 77 | -------------------------------------------------------------------------------- /src/server/db/migrations/0001_silky_patch.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "comments" DROP CONSTRAINT "comments_post_id_posts_id_fk"; 2 | --> statement-breakpoint 3 | ALTER TABLE "likes" DROP CONSTRAINT "likes_post_id_posts_id_fk"; 4 | --> statement-breakpoint 5 | ALTER TABLE "notifications" DROP CONSTRAINT "notifications_post_id_posts_id_fk"; 6 | --> statement-breakpoint 7 | ALTER TABLE "notifications" DROP CONSTRAINT "notifications_comment_id_comments_id_fk"; 8 | --> statement-breakpoint 9 | CREATE INDEX IF NOT EXISTS "comments_post_id_idx" ON "comments" ("post_id");--> statement-breakpoint 10 | CREATE INDEX IF NOT EXISTS "likes_post_id_idx" ON "likes" ("post_id");--> statement-breakpoint 11 | CREATE INDEX IF NOT EXISTS "notifications_post_id_idx" ON "notifications" ("post_id");--> statement-breakpoint 12 | CREATE INDEX IF NOT EXISTS "notifications_comment_id_idx" ON "notifications" ("post_id"); -------------------------------------------------------------------------------- /src/server/db/migrations/0002_reflective_warhawk.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS "comments_post_id_idx";--> statement-breakpoint 2 | DROP INDEX IF EXISTS "likes_post_id_idx";--> statement-breakpoint 3 | DO $$ BEGIN 4 | ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE no action ON UPDATE no action; 5 | EXCEPTION 6 | WHEN duplicate_object THEN null; 7 | END $$; 8 | --> statement-breakpoint 9 | DO $$ BEGIN 10 | ALTER TABLE "likes" ADD CONSTRAINT "likes_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE no action ON UPDATE no action; 11 | EXCEPTION 12 | WHEN duplicate_object THEN null; 13 | END $$; 14 | -------------------------------------------------------------------------------- /src/server/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "pg", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1708243465089, 9 | "tag": "0000_lame_silver_centurion", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "5", 15 | "when": 1708245371978, 16 | "tag": "0001_silky_patch", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "5", 22 | "when": 1708246292630, 23 | "tag": "0002_reflective_warhawk", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /src/server/db/schema/comments.ts: -------------------------------------------------------------------------------- 1 | import { InferModel } from "drizzle-orm"; 2 | import { index, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; 3 | import { posts } from "./posts"; 4 | 5 | export const comments = pgTable( 6 | "comments", 7 | { 8 | id: varchar("id", { length: 191 }).notNull().primaryKey(), 9 | ownerId: varchar("owner_id", { length: 191 }).notNull(), 10 | content: text("content").notNull(), 11 | postId: varchar("post_id", { length: 191 }) 12 | .notNull() 13 | .references(() => posts.id), 14 | createdAt: timestamp("created_at").notNull().defaultNow(), 15 | }, 16 | (table) => ({ 17 | ownerIdIdx: index("comments_owner_id_idx").on(table.ownerId), 18 | }) 19 | ); 20 | 21 | export type Comment = InferModel; 22 | -------------------------------------------------------------------------------- /src/server/db/schema/likes.ts: -------------------------------------------------------------------------------- 1 | import { InferModel } from "drizzle-orm"; 2 | import { 3 | index, 4 | pgTable, 5 | timestamp, 6 | uniqueIndex, 7 | varchar, 8 | } from "drizzle-orm/pg-core"; 9 | import { posts } from "./posts"; 10 | 11 | export const likes = pgTable( 12 | "likes", 13 | { 14 | id: varchar("id", { length: 191 }).notNull().primaryKey(), 15 | ownerId: varchar("owner_id", { length: 191 }).notNull(), 16 | postId: varchar("post_id", { length: 191 }) 17 | .notNull() 18 | .references(() => posts.id), 19 | createdAt: timestamp("created_at").notNull().defaultNow(), 20 | }, 21 | (table) => ({ 22 | ownerIdIdx: index("likes_owner_id_idx").on(table.ownerId), 23 | uniqueIdx: uniqueIndex("likes_unique_idx").on( 24 | table.postId, 25 | table.ownerId 26 | ), 27 | }) 28 | ); 29 | 30 | export type Like = InferModel; 31 | -------------------------------------------------------------------------------- /src/server/db/schema/notifications.ts: -------------------------------------------------------------------------------- 1 | import { InferModel } from "drizzle-orm"; 2 | import { 3 | index, 4 | pgEnum, 5 | pgTable, 6 | timestamp, 7 | varchar, 8 | } from "drizzle-orm/pg-core"; 9 | import { posts } from "./posts"; 10 | import { comments } from "./comments"; 11 | 12 | export const githubAction = pgEnum("github_action", ["follow", "star", "share"]); 13 | export const postAction = pgEnum("post_action", ["comment", "like"]); 14 | 15 | export const notifications = pgTable( 16 | "notifications", 17 | { 18 | id: varchar("id", { length: 191 }).notNull().primaryKey(), 19 | githubAction: githubAction("github_action"), 20 | repoName: varchar("repo_name", { length: 256 }), 21 | postAction: postAction("post_action"), 22 | postId: varchar("post_id", { length: 191 }), 23 | commentId: varchar("comment_id", { length: 191 }), 24 | originId: varchar("origin_id", { length: 191 }).notNull(), 25 | receiverId: varchar("receiver_id", { length: 191 }).notNull(), 26 | createdAt: timestamp("created_at").notNull().defaultNow(), 27 | }, 28 | (table) => ({ 29 | postIdIdx: index("notifications_post_id_idx").on(table.postId), 30 | commentIdIdx: index("notifications_comment_id_idx").on(table.postId), 31 | receiverIdIdx: index("notifications_receiver_id_idx").on(table.receiverId), 32 | }) 33 | ); 34 | 35 | export type Notification = InferModel; 36 | export type NotificationInsert = InferModel; 37 | -------------------------------------------------------------------------------- /src/server/db/schema/posts.ts: -------------------------------------------------------------------------------- 1 | import type { InferModel } from "drizzle-orm"; 2 | import { index, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core"; 3 | 4 | export const posts = pgTable( 5 | "posts", 6 | { 7 | id: varchar("id", { length: 191 }).notNull().primaryKey(), 8 | ownerId: varchar("owner_id", { length: 191 }).notNull(), 9 | content: text("content").notNull(), 10 | repoShared: varchar("repo_shared", { length: 256 }), 11 | createdAt: timestamp("created_at").notNull().defaultNow(), 12 | }, 13 | (table) => ({ 14 | ownerIdIdx: index("posts_owner_id_idx").on(table.ownerId), 15 | repoSharedIdx: index("posts_repo_shared_idx").on(table.repoShared), 16 | }) 17 | ); 18 | 19 | export type Post = InferModel; 20 | -------------------------------------------------------------------------------- /src/server/helpers/clerk.ts: -------------------------------------------------------------------------------- 1 | import { clerkClient } from "@clerk/nextjs/server"; 2 | import { TRPCError } from "@trpc/server"; 3 | 4 | export const getUsernameFromClerkOrThrow = async (userId: string) => { 5 | const user = await clerkClient.users.getUser(userId); 6 | 7 | if (!user?.username) { 8 | throw new TRPCError({ 9 | code: "FORBIDDEN", 10 | message: "Username not found", 11 | }); 12 | } 13 | return user.username; 14 | }; 15 | -------------------------------------------------------------------------------- /src/server/helpers/drizzleQueries.ts: -------------------------------------------------------------------------------- 1 | import { eq, sql } from "drizzle-orm"; 2 | import { posts } from "../db/schema/posts"; 3 | import { comments } from "../db/schema/comments"; 4 | import { likes } from "../db/schema/likes"; 5 | import { NeonHttpDatabase } from "drizzle-orm/neon-http"; 6 | 7 | export const getPostsWithCommentsCountAndLikesCountQuery = ( 8 | db: NeonHttpDatabase> 9 | ) => { 10 | const postCommentsSq = db 11 | .select({ 12 | postId: posts.id, 13 | commentsCount: sql`count(${comments.id})`.as("comments_count"), 14 | }) 15 | .from(posts) 16 | .leftJoin(comments, eq(comments.postId, posts.id)) 17 | .groupBy(posts.id) 18 | .as("post_comments_sq"); 19 | 20 | const postLikesSq = db 21 | .select({ 22 | postId: posts.id, 23 | likesCount: sql`count(${likes.id})`.as("likes_count"), 24 | }) 25 | .from(posts) 26 | .leftJoin(likes, eq(likes.postId, posts.id)) 27 | .groupBy(posts.id) 28 | .as("post_likes_sq"); 29 | 30 | return db 31 | .select({ 32 | post: posts, 33 | commentsCount: postCommentsSq.commentsCount, 34 | likesCount: postLikesSq.likesCount, 35 | }) 36 | .from(posts) 37 | .leftJoin(postCommentsSq, eq(postCommentsSq.postId, posts.id)) 38 | .leftJoin(postLikesSq, eq(postLikesSq.postId, posts.id)) 39 | .groupBy(posts.id, postCommentsSq.commentsCount, postLikesSq.likesCount); 40 | }; 41 | -------------------------------------------------------------------------------- /src/server/helpers/getMinuteDiff.ts: -------------------------------------------------------------------------------- 1 | export const getSecondsDifferenceFromNow = (prevDate: Date) => { 2 | const currDate = new Date(); 3 | const diffInMilliseconds = Math.abs(currDate.getTime() - prevDate.getTime()); 4 | const diffInSeconds = Math.floor(diffInMilliseconds / 1000); 5 | return diffInSeconds; 6 | }; 7 | -------------------------------------------------------------------------------- /src/server/helpers/githubApi.ts: -------------------------------------------------------------------------------- 1 | import { GitHubRepo, GitHubUserProfile } from "@/types/github"; 2 | 3 | const baseUrl = "https://api.github.com"; 4 | const baseHeaders = { 5 | Accept: "application/vnd.github+json", 6 | "X-GitHub-Api-Version": "2022-11-28", 7 | }; 8 | 9 | const getUserProfile = async (token: string, username: string) => { 10 | const res = await fetch(`${baseUrl}/users/${username}`, { 11 | headers: { 12 | ...baseHeaders, 13 | Authorization: `Bearer ${token}`, 14 | }, 15 | }); 16 | if (res.status !== 200) return; 17 | return (await res.json()) as GitHubUserProfile; 18 | }; 19 | 20 | const getFollowerLists = async ( 21 | token: string, 22 | username: string, 23 | page: number, 24 | perPage: number 25 | ) => { 26 | const res = await fetch( 27 | `${baseUrl}/users/${username}/followers?page=${page}&per_page=${perPage}`, 28 | { 29 | headers: { 30 | ...baseHeaders, 31 | Authorization: `Bearer ${token}`, 32 | }, 33 | } 34 | ); 35 | if (res.status !== 200) return [] 36 | return (await res.json()) as GitHubUserProfile[]; 37 | }; 38 | 39 | const getFollowingLists = async ( 40 | token: string, 41 | username: string, 42 | page: number, 43 | perPage: number 44 | ) => { 45 | const res = await fetch( 46 | `${baseUrl}/users/${username}/following?page=${page}&per_page=${perPage}`, 47 | { 48 | headers: { 49 | ...baseHeaders, 50 | Authorization: `Bearer ${token}`, 51 | }, 52 | } 53 | ); 54 | if (res.status !== 200) return [] 55 | return (await res.json()) as GitHubUserProfile[]; 56 | }; 57 | 58 | const amIFollowingTheUser = async (token: string, username: string) => { 59 | const res = await fetch(`${baseUrl}/user/following/${username}`, { 60 | headers: { 61 | ...baseHeaders, 62 | Authorization: `Bearer ${token}`, 63 | }, 64 | }); 65 | return res.status === 204; 66 | }; 67 | 68 | const followAction = async ( 69 | token: string, 70 | username: string, 71 | action: "follow" | "unfollow" 72 | ) => { 73 | const res = await fetch(`${baseUrl}/user/following/${username}`, { 74 | method: action === "unfollow" ? "DELETE" : "PUT", 75 | headers: { 76 | ...baseHeaders, 77 | Authorization: `Bearer ${token}`, 78 | }, 79 | }); 80 | return res.status === 204; 81 | }; 82 | 83 | const starAction = async ( 84 | token: string, 85 | repoName: string, 86 | action: "star" | "unstar" 87 | ) => { 88 | const res = await fetch(`${baseUrl}/user/starred/${repoName}`, { 89 | method: action === "unstar" ? "DELETE" : "PUT", 90 | headers: { 91 | ...baseHeaders, 92 | Authorization: `Bearer ${token}`, 93 | }, 94 | }); 95 | return res.status === 204; 96 | }; 97 | 98 | const hasIStarredTheRepo = async (token: string, repoName: string) => { 99 | const res = await fetch(`${baseUrl}/user/starred/${repoName}`, { 100 | headers: { 101 | Accept: "application/vnd.github+json", 102 | Authorization: `Bearer ${token}`, 103 | "X-GitHub-Api-Version": "2022-11-28", 104 | }, 105 | }); 106 | return res.status === 204; 107 | }; 108 | 109 | const getARepo = async (token: string, repoName: string) => { 110 | const res = await fetch(`${baseUrl}/repos/${repoName}`, { 111 | headers: { 112 | ...baseHeaders, 113 | Authorization: `Bearer ${token}`, 114 | }, 115 | }); 116 | if (res.status !== 200) return; 117 | return (await res.json()) as GitHubRepo; 118 | }; 119 | 120 | const myRepoLists = async (token: string, page: number, perPage: number) => { 121 | const res = await fetch( 122 | `${baseUrl}/user/repos?page=${page}&per_page=${perPage}&visibility=public&sort=pushed`, 123 | { 124 | headers: { 125 | ...baseHeaders, 126 | Authorization: `Bearer ${token}`, 127 | }, 128 | } 129 | ); 130 | if (res.status !== 200) return []; 131 | return (await res.json()) as GitHubRepo[]; 132 | }; 133 | 134 | const otherUserRepoLists = async ( 135 | token: string, 136 | username: string, 137 | page: number, 138 | perPage: number 139 | ) => { 140 | const res = await fetch( 141 | `${baseUrl}/users/${username}/repos?page=${page}&per_page=${perPage}&sort=pushed`, 142 | { 143 | headers: { 144 | ...baseHeaders, 145 | Authorization: `Bearer ${token}`, 146 | }, 147 | } 148 | ); 149 | if (res.status !== 200) return []; 150 | return (await res.json()) as GitHubRepo[]; 151 | }; 152 | 153 | const githubApi = { 154 | getUserProfile, 155 | getFollowerLists, 156 | getFollowingLists, 157 | amIFollowingTheUser, 158 | followAction, 159 | starAction, 160 | hasIStarredTheRepo, 161 | getARepo, 162 | myRepoLists, 163 | otherUserRepoLists, 164 | }; 165 | 166 | export default githubApi; 167 | -------------------------------------------------------------------------------- /src/server/helpers/notifications.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Notification, 3 | NotificationInsert, 4 | notifications, 5 | } from "../db/schema/notifications"; 6 | import { v4 } from "uuid"; 7 | import { env } from "@/env.mjs"; 8 | import { TRPCError } from "@trpc/server"; 9 | import pusherApi from "./pusher"; 10 | import { NeonHttpDatabase } from "drizzle-orm/neon-http"; 11 | 12 | export const postNotification = async ( 13 | db: NeonHttpDatabase>, 14 | inputs: Omit 15 | ) => { 16 | const { 17 | originId, 18 | receiverId, 19 | githubAction = null, 20 | repoName = null, 21 | postAction = null, 22 | postId = null, 23 | commentId = null, 24 | } = inputs; 25 | 26 | /** 27 | * - githubAction requires repoName (except "follow") 28 | * - postAction requires postId (except "comment", it required commentId) 29 | * - if the originId and receiverId is the same, do not send notification 30 | */ 31 | if ( 32 | (githubAction && githubAction !== "follow" && !repoName) || 33 | (postAction && !postId) || 34 | (postAction && postAction === "comment" && !commentId) || 35 | // for easier test in development 36 | (env.NODE_ENV === "production" && receiverId === originId) 37 | ) 38 | return; 39 | 40 | const newNotification: Notification = { 41 | id: v4(), 42 | githubAction, 43 | repoName, 44 | postAction, 45 | postId, 46 | commentId, 47 | createdAt: new Date(), 48 | originId, 49 | receiverId, 50 | }; 51 | await db 52 | .insert(notifications) 53 | .values(newNotification) 54 | .catch(() => { 55 | throw new TRPCError({ 56 | code: "INTERNAL_SERVER_ERROR", 57 | message: "Unable to push notification", 58 | }); 59 | }); 60 | 61 | await pusherApi.pushNotification(receiverId); 62 | }; 63 | -------------------------------------------------------------------------------- /src/server/helpers/pusher.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env.mjs"; 2 | 3 | const baseUrl = (() => { 4 | if (typeof window !== "undefined") return ""; // browser should use relative url 5 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url 6 | return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost 7 | })(); 8 | 9 | const pushNotification = async (receiverId: string) => { 10 | await fetch(baseUrl + "/api/pusher/push-notification", { 11 | method: "POST", 12 | body: JSON.stringify({ receiverId }), 13 | headers: { 14 | "x-api-key": env.PUSHER_API_KEY, 15 | }, 16 | }); 17 | }; 18 | 19 | const pusherApi = { 20 | pushNotification, 21 | }; 22 | 23 | export default pusherApi; 24 | -------------------------------------------------------------------------------- /src/server/helpers/trimGitHubData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GitHubRepo, 3 | GitHubUserProfile, 4 | TrimmedGitHubProfile, 5 | TrimmedGitHubRepo, 6 | } from "@/types/github"; 7 | 8 | export const trimGitHubProfileData = (profile: GitHubUserProfile) => { 9 | const trimmedProfile: TrimmedGitHubProfile = { 10 | id: profile.id, 11 | node_id: profile.node_id, 12 | name: profile.name, 13 | login: profile.login, 14 | avatar_url: profile.avatar_url, 15 | bio: profile.bio, 16 | blog: profile.blog, 17 | followers: profile.followers, 18 | following: profile.following, 19 | html_url: profile.html_url, 20 | type: profile.type, 21 | company: profile.company, 22 | }; 23 | return trimmedProfile; 24 | }; 25 | 26 | export const trimGitHubRepoData = (repo: GitHubRepo) => { 27 | const trimmedRepo: TrimmedGitHubRepo = { 28 | id: repo.id, 29 | node_id: repo.node_id, 30 | name: repo.name, 31 | full_name: repo.full_name, 32 | html_url: repo.html_url, 33 | description: repo.description, 34 | fork: repo.fork, 35 | forks_count: repo.forks_count, 36 | stargazers_count: repo.stargazers_count, 37 | watchers_count: repo.watchers_count, 38 | topics: repo.topics, 39 | owner: trimGitHubProfileData(repo.owner), 40 | }; 41 | return trimmedRepo; 42 | }; 43 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 47.4% 11.2%; 18 | 19 | --border: 214.3 31.8% 91.4%; 20 | --input: 214.3 31.8% 91.4%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --popover: 224 71% 4%; 47 | --popover-foreground: 215 20.2% 65.1%; 48 | 49 | --card: 0 0% 100%; 50 | --card-foreground: 222.2 47.4% 11.2%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --primary: 210 40% 98%; 56 | --primary-foreground: 222.2 47.4% 1.2%; 57 | 58 | --secondary: 222.2 47.4% 11.2%; 59 | --secondary-foreground: 210 40% 98%; 60 | 61 | --accent: 216 34% 17%; 62 | --accent-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground text-gray-400; 79 | font-feature-settings: "rlig" 1, "calt" 1; 80 | } 81 | } -------------------------------------------------------------------------------- /src/types/github.ts: -------------------------------------------------------------------------------- 1 | export interface GitHubUserProfile { 2 | login: string; 3 | id: number; 4 | node_id: string; 5 | avatar_url: string; 6 | url: string; 7 | html_url: string; 8 | followers_url: string; 9 | following_url: string; 10 | gists_url: string; 11 | starred_url: string; 12 | subscriptions_url: string; 13 | organizations_url: string; 14 | repos_url: string; 15 | events_url: string; 16 | received_events_url: string; 17 | type: string; 18 | site_admin: boolean; 19 | name?: string; 20 | company?: string; 21 | blog?: string; 22 | location?: string; 23 | email: string; 24 | hireable?: boolean; 25 | bio?: string; 26 | twitter_username?: string; 27 | public_repos: number; 28 | public_gists: number; 29 | followers: number; 30 | following: number; 31 | created_at: string; 32 | updated_at: string; 33 | private_gists: number; 34 | total_private_repos: number; 35 | owned_private_repos: number; 36 | disk_usage: number; 37 | collaborators: number; 38 | two_factor_authentication: boolean; 39 | plan: { 40 | name: string; 41 | space: number; 42 | collaborators: number; 43 | private_repos: number; 44 | }; 45 | } 46 | 47 | export type TrimmedGitHubProfile = Pick< 48 | GitHubUserProfile, 49 | | "id" 50 | | "node_id" 51 | | "name" 52 | | "login" 53 | | "avatar_url" 54 | | "bio" 55 | | "blog" 56 | | "company" 57 | | "followers" 58 | | "following" 59 | | "html_url" 60 | | "type" 61 | >; 62 | 63 | export type GitHubRepo = { 64 | id: number; 65 | node_id: string; 66 | name: string; 67 | full_name: string; 68 | owner: GitHubUserProfile; 69 | private: boolean; 70 | html_url: string; 71 | description: string; 72 | fork: boolean; 73 | url: string; 74 | archive_url: string; 75 | assignees_url: string; 76 | blobs_url: string; 77 | branches_url: string; 78 | collaborators_url: string; 79 | comments_url: string; 80 | commits_url: string; 81 | compare_url: string; 82 | contents_url: string; 83 | contributors_url: string; 84 | deployments_url: string; 85 | downloads_url: string; 86 | events_url: string; 87 | forks_url: string; 88 | git_commits_url: string; 89 | git_refs_url: string; 90 | git_tags_url: string; 91 | git_url: string; 92 | issue_comment_url: string; 93 | issue_events_url: string; 94 | issues_url: string; 95 | keys_url: string; 96 | labels_url: string; 97 | languages_url: string; 98 | merges_url: string; 99 | milestones_url: string; 100 | notifications_url: string; 101 | pulls_url: string; 102 | releases_url: string; 103 | ssh_url: string; 104 | stargazers_url: string; 105 | statuses_url: string; 106 | subscribers_url: string; 107 | subscription_url: string; 108 | tags_url: string; 109 | teams_url: string; 110 | trees_url: string; 111 | mirror_url: string; 112 | hooks_url: string; 113 | svn_url: string; 114 | homepage?: string; 115 | language?: string; 116 | forks_count: number; 117 | stargazers_count: number; 118 | watchers_count: number; 119 | size: number; 120 | default_branch: string; 121 | open_issues_count: number; 122 | is_template: boolean; 123 | topics: string[]; 124 | has_issues: boolean; 125 | has_projects: boolean; 126 | has_wiki: boolean; 127 | has_pages: boolean; 128 | has_downloads: boolean; 129 | archived: boolean; 130 | disabled: boolean; 131 | visibility: string; 132 | pushed_at: string; 133 | created_at: string; 134 | updated_at: string; 135 | permissions: { 136 | admin: boolean; 137 | push: boolean; 138 | pull: boolean; 139 | }; 140 | allow_rebase_merge: boolean; 141 | template_repository?: string; 142 | temp_clone_token: string; 143 | allow_squash_merge: boolean; 144 | allow_auto_merge: boolean; 145 | delete_branch_on_merge: boolean; 146 | allow_merge_commit: boolean; 147 | subscribers_count: number; 148 | network_count: number; 149 | license?: { 150 | key: string; 151 | name: string; 152 | url: string; 153 | spdx_id: string; 154 | node_id: string; 155 | html_url: string; 156 | }; 157 | forks: number; 158 | open_issues: number; 159 | watchers: number; 160 | }; 161 | 162 | export type TrimmedGitHubRepo = Pick< 163 | GitHubRepo, 164 | | "id" 165 | | "node_id" 166 | | "name" 167 | | "full_name" 168 | | "html_url" 169 | | "description" 170 | | "fork" 171 | | "forks_count" 172 | | "stargazers_count" 173 | | "watchers_count" 174 | | "topics" 175 | > & { 176 | owner: TrimmedGitHubProfile; 177 | }; 178 | -------------------------------------------------------------------------------- /src/validationSchemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const idSchema = z.object({ 4 | id: z.string().min(1), 5 | }); 6 | 7 | export const paginationSchema = z.object({ 8 | page: z.number(), 9 | perPage: z.number(), 10 | }); 11 | 12 | export const createPostSchema = z.object({ 13 | content: z.string().min(1).max(100), 14 | repoShared: z.string().optional(), 15 | }); 16 | 17 | export const createCommentSchema = z.object({ 18 | content: z.string().min(1).max(100), 19 | postId: z.string().min(1), 20 | }); 21 | 22 | export const likeActionSchema = z.object({ 23 | postId: z.string().min(1), 24 | action: z.enum(["like", "unlike"]), 25 | }); 26 | 27 | export const githubRepoSchema = z.object({ 28 | repoName: z.string().min(1), 29 | }); 30 | 31 | export const publishNotificationSchema = z.object({ 32 | receiverId: z.string().min(1), 33 | }); 34 | 35 | export const publishChatSchema = z.object({ 36 | id: z.string().min(1), 37 | createdAt: z.string().min(1), 38 | receiverId: z.string().min(1), 39 | senderId: z.string().min(1), 40 | text: z.string().min(1), 41 | }); 42 | 43 | // Types 44 | export type CreatePostInput = z.infer; 45 | export type CreateCommentInput = z.infer; 46 | export type PublishNotification = z.infer; 47 | export type PublishChat = z.infer; 48 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: ["class"], 4 | content: ["./src/**/*.{ts,tsx}"], 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: "2rem", 9 | screens: { 10 | "2xl": "1400px", 11 | }, 12 | }, 13 | extend: { 14 | colors: { 15 | border: "hsl(var(--border))", 16 | input: "hsl(var(--input))", 17 | ring: "hsl(var(--ring))", 18 | background: "hsl(var(--background))", 19 | foreground: "hsl(var(--foreground))", 20 | primary: { 21 | DEFAULT: "hsl(var(--primary))", 22 | foreground: "hsl(var(--primary-foreground))", 23 | }, 24 | secondary: { 25 | DEFAULT: "hsl(var(--secondary))", 26 | foreground: "hsl(var(--secondary-foreground))", 27 | }, 28 | destructive: { 29 | DEFAULT: "hsl(var(--destructive))", 30 | foreground: "hsl(var(--destructive-foreground))", 31 | }, 32 | muted: { 33 | DEFAULT: "hsl(var(--muted))", 34 | foreground: "hsl(var(--muted-foreground))", 35 | }, 36 | accent: { 37 | DEFAULT: "hsl(var(--accent))", 38 | foreground: "hsl(var(--accent-foreground))", 39 | }, 40 | popover: { 41 | DEFAULT: "hsl(var(--popover))", 42 | foreground: "hsl(var(--popover-foreground))", 43 | }, 44 | card: { 45 | DEFAULT: "hsl(var(--card))", 46 | foreground: "hsl(var(--card-foreground))", 47 | }, 48 | }, 49 | borderRadius: { 50 | lg: "var(--radius)", 51 | md: "calc(var(--radius) - 2px)", 52 | sm: "calc(var(--radius) - 4px)", 53 | }, 54 | keyframes: { 55 | "accordion-down": { 56 | from: { height: 0 }, 57 | to: { height: "var(--radix-accordion-content-height)" }, 58 | }, 59 | "accordion-up": { 60 | from: { height: "var(--radix-accordion-content-height)" }, 61 | to: { height: 0 }, 62 | }, 63 | }, 64 | animation: { 65 | "accordion-down": "accordion-down 0.2s ease-out", 66 | "accordion-up": "accordion-up 0.2s ease-out", 67 | }, 68 | }, 69 | }, 70 | plugins: [require("tailwindcss-animate")], 71 | }; 72 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 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 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/env.mjs", "drizzle.config.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------