├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── onboarding │ │ └── page.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (root) │ ├── (home) │ │ ├── loading.tsx │ │ └── page.tsx │ ├── ask-question │ │ └── page.tsx │ ├── collection │ │ ├── loading.tsx │ │ └── page.tsx │ ├── community │ │ ├── loading.tsx │ │ └── page.tsx │ ├── edit-answer │ │ └── [id] │ │ │ └── page.tsx │ ├── jobs │ │ ├── loading.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── profile │ │ ├── [id] │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ └── edit │ │ │ └── page.tsx │ ├── question │ │ ├── [id] │ │ │ └── page.tsx │ │ └── edit │ │ │ └── [id] │ │ │ └── page.tsx │ └── tags │ │ ├── [id] │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── loading.tsx │ │ └── page.tsx ├── api │ ├── openai │ │ └── route.ts │ ├── rapidapi │ │ └── route.ts │ └── webhook │ │ └── clerk │ │ └── route.ts ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── cards │ ├── AnswerCard.tsx │ ├── JobCard.tsx │ ├── QuestionCard.tsx │ └── UserCard.tsx ├── forms │ ├── Answer.tsx │ ├── Profile.tsx │ └── Question.tsx ├── jobs │ └── JobBadge.tsx ├── shared │ ├── AllAnswers.tsx │ ├── AnswersTab.tsx │ ├── EditDeleteAction.tsx │ ├── Filter.tsx │ ├── Filters.tsx │ ├── LeftSidebar.tsx │ ├── Metric.tsx │ ├── NoResult.tsx │ ├── Pagination.tsx │ ├── ParseHTML.tsx │ ├── ProfileLink.tsx │ ├── QuestionsTab.tsx │ ├── RenderTag.tsx │ ├── RightSidebar.tsx │ ├── Stats.tsx │ ├── Switcher.tsx │ ├── Votes.tsx │ ├── navbar │ │ ├── Mobile.tsx │ │ ├── Navbar.tsx │ │ └── Theme.tsx │ └── search │ │ ├── GlobalFilters.tsx │ │ ├── GlobalResult.tsx │ │ ├── GlobalSearch.tsx │ │ └── LocalSearchbar.tsx └── ui │ ├── badge.tsx │ ├── button.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── select.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ └── use-toast.ts ├── constants ├── filters.ts └── index.ts ├── content ├── countries.json └── jsearch.json ├── context └── ThemeProvider.tsx ├── database ├── answer.model.ts ├── interaction.model.ts ├── question.model.ts ├── tag.model.ts └── user.model.ts ├── lib ├── actions │ ├── answer.action.ts │ ├── general.action.ts │ ├── interaction.action.ts │ ├── job.action.ts │ ├── question.action.ts │ ├── shared.types.d.ts │ ├── tag.action.ts │ └── user.action.ts ├── mongoose.ts ├── utils.ts └── validations.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── icons │ │ ├── account.svg │ │ ├── arrow-left.svg │ │ ├── arrow-right.svg │ │ ├── arrow-up-right.svg │ │ ├── au.svg │ │ ├── avatar.svg │ │ ├── briefcase.svg │ │ ├── bronze-medal.svg │ │ ├── calendar.svg │ │ ├── carbon-location.svg │ │ ├── chevron-down.svg │ │ ├── chevron-right.svg │ │ ├── clock-2.svg │ │ ├── clock.svg │ │ ├── close.svg │ │ ├── computer.svg │ │ ├── currency-dollar-circle.svg │ │ ├── downvote.svg │ │ ├── downvoted.svg │ │ ├── edit.svg │ │ ├── eye.svg │ │ ├── gold-medal.svg │ │ ├── hamburger.svg │ │ ├── home.svg │ │ ├── job-search.svg │ │ ├── like.svg │ │ ├── link.svg │ │ ├── location.svg │ │ ├── message.svg │ │ ├── mingcute-down-line.svg │ │ ├── moon.svg │ │ ├── people.svg │ │ ├── question.svg │ │ ├── search.svg │ │ ├── sign-up.svg │ │ ├── silver-medal.svg │ │ ├── star-filled.svg │ │ ├── star-red.svg │ │ ├── star.svg │ │ ├── stars.svg │ │ ├── suitcase.svg │ │ ├── sun.svg │ │ ├── tag.svg │ │ ├── trash.svg │ │ ├── upvote.svg │ │ ├── upvoted.svg │ │ ├── user.svg │ │ └── users.svg │ └── images │ │ ├── auth-dark.png │ │ ├── auth-light.png │ │ ├── dark-illustration.png │ │ ├── default-logo.svg │ │ ├── light-illustration.png │ │ ├── logo-dark.svg │ │ ├── logo-light.svg │ │ ├── logo.png │ │ └── site-logo.svg ├── next.svg └── vercel.svg ├── styles ├── prism.css └── theme.css ├── tailwind.config.ts ├── tsconfig.json └── types └── index.d.ts /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 2 | CLERK_SECRET_KEY= 3 | 4 | CLERK_WEBHOOK_SECRET= 5 | 6 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 7 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 8 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 9 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding 10 | 11 | NEXT_PUBLIC_TINY_MCE_API_KEY= 12 | 13 | MONGODB_URL= 14 | 15 | NEXT_PUBLIC_SERVER_URL= 16 | 17 | OPENAI_API_KEY= 18 | 19 | RAPID_API_KEY= 20 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "standard", 5 | "plugin:tailwindcss/recommended", 6 | "prettier" 7 | ], 8 | "ignorePatterns": ["/components/ui/*"] 9 | } 10 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true, 6 | "source.addMissingImports": true 7 | }, 8 | "[typescriptreact]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Liron Abutbul 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 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import type { Metadata } from "next"; 4 | 5 | export const metadata: Metadata = { 6 | title: "Auth — DevOverflow", 7 | }; 8 | 9 | export default function AuthLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 |
16 | {children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/(auth)/onboarding/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Profile from "@/components/forms/Profile"; 5 | 6 | import { getUserById } from "@/lib/actions/user.action"; 7 | 8 | const Page = async () => { 9 | const { userId } = auth(); 10 | if (!userId) return null; 11 | 12 | const mongoUser = await getUserById({ userId }); 13 | if (mongoUser?.onboarded) redirect("/"); 14 | 15 | return ( 16 | <> 17 |
18 |

Onboarding

19 |

20 | Complete your profile now to use DevOverflow 21 |

22 | 23 |
24 | 25 |
26 |
27 | 28 | ); 29 | }; 30 | 31 | export default Page; 32 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(root)/(home)/loading.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { Skeleton } from "@/components/ui/skeleton"; 5 | 6 | const Loading = () => { 7 | return ( 8 |
9 |
10 |

All Questions

11 | 12 | 13 | 16 | 17 |
18 | 19 |
20 | 21 |
22 | 23 |
24 |
25 | 26 |
27 | 28 | 29 | 30 | 31 |
32 | 33 |
34 | {[...Array(10)].map((_, i) => ( 35 | 36 | ))} 37 |
38 |
39 | ); 40 | }; 41 | 42 | export default Loading; 43 | -------------------------------------------------------------------------------- /app/(root)/(home)/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { auth } from "@clerk/nextjs"; 4 | 5 | import { Button } from "@/components/ui/button"; 6 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 7 | import Filter from "@/components/shared/Filter"; 8 | import NoResult from "@/components/shared/NoResult"; 9 | import Pagination from "@/components/shared/Pagination"; 10 | import HomeFilters from "@/components/shared/Filters"; 11 | import QuestionCard from "@/components/cards/QuestionCard"; 12 | 13 | import { 14 | getQuestions, 15 | getRecommendedQuestions, 16 | } from "@/lib/actions/question.action"; 17 | 18 | import { HomePageFilters } from "@/constants/filters"; 19 | 20 | import type { SearchParamsProps } from "@/types"; 21 | import type { Metadata } from "next"; 22 | 23 | export const metadata: Metadata = { 24 | title: "Home — DevOverflow", 25 | }; 26 | 27 | export default async function Home({ searchParams }: SearchParamsProps) { 28 | const { userId: clerkId } = auth(); 29 | 30 | let result; 31 | 32 | if (searchParams?.filter === "recommended") { 33 | if (clerkId) { 34 | result = await getRecommendedQuestions({ 35 | userId: clerkId, 36 | searchQuery: searchParams.q, 37 | page: searchParams.page ? +searchParams.page : 1, 38 | }); 39 | } else { 40 | result = { 41 | questions: [], 42 | isNext: false, 43 | }; 44 | } 45 | } else { 46 | result = await getQuestions({ 47 | searchQuery: searchParams.q, 48 | filter: searchParams.filter, 49 | page: searchParams.page ? +searchParams.page : 1, 50 | }); 51 | } 52 | 53 | return ( 54 | <> 55 |
56 |

All Questions

57 | 58 | 59 | 62 | 63 |
64 | 65 |
66 | 73 | 74 | 79 |
80 | 81 | 82 | 83 |
84 | {result.questions.length > 0 ? ( 85 | result.questions.map((question: any) => ( 86 | 98 | )) 99 | ) : ( 100 | 108 | )} 109 |
110 | 111 |
112 | 116 |
117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /app/(root)/ask-question/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Question from "@/components/forms/Question"; 5 | 6 | import { getUserById } from "@/lib/actions/user.action"; 7 | import type { Metadata } from "next"; 8 | 9 | export const metadata: Metadata = { 10 | title: "Ask a Question — DevOverflow", 11 | }; 12 | 13 | const Page = async () => { 14 | const { userId } = auth(); 15 | 16 | if (!userId) return null; 17 | 18 | const mongoUser = await getUserById({ userId }); 19 | if (!mongoUser?.onboarded) redirect("/onboarding"); 20 | 21 | return ( 22 |
23 |

Ask a Question

24 | 25 |
26 | 27 |
28 |
29 | ); 30 | }; 31 | 32 | export default Page; 33 | -------------------------------------------------------------------------------- /app/(root)/collection/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |

Saved Questions

7 | 8 |
9 | 10 | 11 |
12 | 13 |
14 | {[...Array(10)].map((_, i) => ( 15 | 16 | ))} 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Loading; 23 | -------------------------------------------------------------------------------- /app/(root)/collection/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 5 | import Filter from "@/components/shared/Filter"; 6 | import NoResult from "@/components/shared/NoResult"; 7 | import Pagination from "@/components/shared/Pagination"; 8 | import QuestionCard from "@/components/cards/QuestionCard"; 9 | 10 | import { getSavedQuestions, getUserById } from "@/lib/actions/user.action"; 11 | 12 | import { QuestionFilters } from "@/constants/filters"; 13 | 14 | import type { SearchParamsProps } from "@/types"; 15 | import type { Metadata } from "next"; 16 | 17 | export const metadata: Metadata = { 18 | title: "Collection — DevOverflow", 19 | }; 20 | 21 | export default async function Collection({ searchParams }: SearchParamsProps) { 22 | const { userId: clerkId } = auth(); 23 | 24 | if (!clerkId) return null; 25 | 26 | const mongoUser = await getUserById({ userId: clerkId }); 27 | if (!mongoUser?.onboarded) redirect("/onboarding"); 28 | 29 | const result = await getSavedQuestions({ 30 | clerkId, 31 | searchQuery: searchParams.q, 32 | filter: searchParams.filter, 33 | page: searchParams.page ? +searchParams.page : 1, 34 | }); 35 | 36 | return ( 37 | <> 38 |

Saved Questions

39 |
40 | 47 | 48 | 52 |
53 | 54 |
55 | {result.questions.length > 0 ? ( 56 | result.questions.map((question: any) => ( 57 | 69 | )) 70 | ) : ( 71 | 77 | )} 78 |
79 | 80 |
81 | 85 |
86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /app/(root)/community/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |

All Users

7 | 8 |
9 | 10 | 11 |
12 | 13 |
14 | {[...Array(10)].map((_, i) => ( 15 | 16 | ))} 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Loading; 23 | -------------------------------------------------------------------------------- /app/(root)/community/page.tsx: -------------------------------------------------------------------------------- 1 | import Filter from "@/components/shared/Filter"; 2 | import NoResult from "@/components/shared/NoResult"; 3 | import Pagination from "@/components/shared/Pagination"; 4 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 5 | import UserCard from "@/components/cards/UserCard"; 6 | 7 | import { getAllUsers } from "@/lib/actions/user.action"; 8 | 9 | import { UserFilters } from "@/constants/filters"; 10 | 11 | import type { SearchParamsProps } from "@/types"; 12 | import type { Metadata } from "next"; 13 | 14 | export const metadata: Metadata = { 15 | title: "Community — DevOverflow", 16 | }; 17 | 18 | const Page = async ({ searchParams }: SearchParamsProps) => { 19 | const result = await getAllUsers({ 20 | searchQuery: searchParams.q, 21 | filter: searchParams.filter, 22 | page: searchParams.page ? +searchParams.page : 1, 23 | }); 24 | 25 | return ( 26 | <> 27 |

All Users

28 | 29 |
30 | 37 | 38 | 42 |
43 | 44 |
45 | {result.users.length > 0 ? ( 46 | result.users.map((user: any) => ( 47 | 48 | )) 49 | ) : ( 50 | 56 | )} 57 |
58 | 59 |
60 | 64 |
65 | 66 | ); 67 | }; 68 | 69 | export default Page; 70 | -------------------------------------------------------------------------------- /app/(root)/edit-answer/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Answer from "@/components/forms/Answer"; 5 | 6 | import { getUserById } from "@/lib/actions/user.action"; 7 | import { getAnswerById } from "@/lib/actions/answer.action"; 8 | 9 | import type { ParamsProps } from "@/types"; 10 | import type { Metadata } from "next"; 11 | 12 | export const metadata: Metadata = { 13 | title: "Edit Answer — DevOverflow", 14 | }; 15 | 16 | const Page = async ({ params }: ParamsProps) => { 17 | const { userId } = auth(); 18 | 19 | if (!userId) return null; 20 | 21 | const mongoUser = await getUserById({ userId }); 22 | if (!mongoUser?.onboarded) redirect("/onboarding"); 23 | 24 | const result = await getAnswerById({ answerId: params.id }); 25 | 26 | if (userId !== result.author.clerkId) redirect("/"); 27 | 28 | return ( 29 | <> 30 |

Edit Answer

31 |
32 | 39 |
40 | 41 | ); 42 | }; 43 | 44 | export default Page; 45 | -------------------------------------------------------------------------------- /app/(root)/jobs/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |

Jobs

7 | 8 |
9 | 10 | 11 |
12 | 13 |
14 |
15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 |
23 |
24 | 25 |
26 | {[...Array(10)].map((_, i) => ( 27 | 28 | ))} 29 |
30 |
31 | ); 32 | }; 33 | 34 | export default Loading; 35 | -------------------------------------------------------------------------------- /app/(root)/jobs/page.tsx: -------------------------------------------------------------------------------- 1 | import Filter from "@/components/shared/Filter"; 2 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 3 | 4 | import JobFilters from "@/components/shared/Filters"; 5 | import NoResult from "@/components/shared/NoResult"; 6 | import Pagination from "@/components/shared/Pagination"; 7 | import JobCard from "@/components/cards/JobCard"; 8 | 9 | import { getCountryFilters, getJobs } from "@/lib/actions/job.action"; 10 | 11 | import { JobPageFilters } from "@/constants/filters"; 12 | 13 | import type { SearchParamsProps } from "@/types"; 14 | import type { Metadata } from "next"; 15 | 16 | export const metadata: Metadata = { 17 | title: "Jobs — DevOverflow", 18 | }; 19 | 20 | const Page = async ({ searchParams }: SearchParamsProps) => { 21 | const CountryFilters = await getCountryFilters(); 22 | 23 | const result = await getJobs({ 24 | searchQuery: searchParams.q, 25 | filter: searchParams.filter, 26 | location: searchParams.location, 27 | remote: searchParams.remote, 28 | page: searchParams.page ? +searchParams.page : 1, 29 | wage: searchParams.wage, 30 | skills: searchParams.skills, 31 | }); 32 | 33 | return ( 34 | <> 35 |

Jobs

36 | 37 |
38 | 45 | {CountryFilters && ( 46 | 51 | )} 52 |
53 | 54 | 55 | 56 |
57 | {result.data.length > 0 ? ( 58 | result.data.map((jobItem: any) => ( 59 | 81 | )) 82 | ) : ( 83 | 89 | )} 90 |
91 | 92 |
93 | 97 |
98 | 99 | ); 100 | }; 101 | 102 | export default Page; 103 | -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Toaster } from "@/components/ui/toaster"; 4 | import Navbar from "@/components/shared/navbar/Navbar"; 5 | import LeftSidebar from "@/components/shared/LeftSidebar"; 6 | import RightSidebar from "@/components/shared/RightSidebar"; 7 | 8 | const Layout = ({ children }: { children: React.ReactNode }) => { 9 | return ( 10 |
11 | 12 |
13 | 14 | 15 |
16 |
{children}
17 |
18 | 19 | 20 |
21 | 22 | 23 |
24 | ); 25 | }; 26 | 27 | export default Layout; 28 | -------------------------------------------------------------------------------- /app/(root)/profile/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 | 11 | 12 | 13 |
14 | 15 | 16 |
17 | 18 | 19 |
20 |
21 | 22 |
23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 |
35 |
36 | 37 | 38 |
39 | 40 |
41 | {[...Array(5)].map((_, i) => ( 42 | 43 | ))} 44 |
45 |
46 | 47 |
48 | 49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | export default Loading; 64 | -------------------------------------------------------------------------------- /app/(root)/profile/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Profile from "@/components/forms/Profile"; 5 | 6 | import { getUserById } from "@/lib/actions/user.action"; 7 | 8 | import type { ParamsProps } from "@/types"; 9 | import type { Metadata } from "next"; 10 | 11 | export const metadata: Metadata = { 12 | title: "Edit Profile — DevOverflow", 13 | }; 14 | 15 | const Page = async ({ params }: ParamsProps) => { 16 | const { userId } = auth(); 17 | 18 | if (!userId) return null; 19 | 20 | const mongoUser = await getUserById({ userId }); 21 | if (!mongoUser?.onboarded) redirect("/onboarding"); 22 | 23 | return ( 24 | <> 25 |

Edit Profile

26 |
27 | 28 |
29 | 30 | ); 31 | }; 32 | 33 | export default Page; 34 | -------------------------------------------------------------------------------- /app/(root)/question/edit/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | import { redirect } from "next/navigation"; 3 | 4 | import Question from "@/components/forms/Question"; 5 | 6 | import { getQuestionById } from "@/lib/actions/question.action"; 7 | import { getUserById } from "@/lib/actions/user.action"; 8 | 9 | import type { ParamsProps } from "@/types"; 10 | import type { Metadata } from "next"; 11 | 12 | export const metadata: Metadata = { 13 | title: "Edit Question — DevOverflow", 14 | }; 15 | 16 | const Page = async ({ params }: ParamsProps) => { 17 | const { userId } = auth(); 18 | 19 | if (!userId) return null; 20 | 21 | const mongoUser = await getUserById({ userId }); 22 | if (!mongoUser?.onboarded) redirect("/onboarding"); 23 | 24 | const result = await getQuestionById({ questionId: params.id }); 25 | 26 | return ( 27 | <> 28 |

Edit Question

29 |
30 | 35 |
36 | 37 | ); 38 | }; 39 | 40 | export default Page; 41 | -------------------------------------------------------------------------------- /app/(root)/tags/[id]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 | 8 | 9 | 10 |
11 | {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((item) => ( 12 | 13 | ))} 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default Loading; 20 | -------------------------------------------------------------------------------- /app/(root)/tags/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs"; 2 | 3 | import QuestionCard from "@/components/cards/QuestionCard"; 4 | import NoResult from "@/components/shared/NoResult"; 5 | import Pagination from "@/components/shared/Pagination"; 6 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 7 | 8 | import { getTagById, getQuestionsByTagId } from "@/lib/actions/tag.action"; 9 | 10 | import type { URLProps } from "@/types"; 11 | import type { Metadata } from "next"; 12 | 13 | export async function generateMetadata({ 14 | params, 15 | }: Omit): Promise { 16 | const tag = await getTagById({ tagId: params.id }); 17 | 18 | return { 19 | title: `Posts by tag '${tag.name}' — DevOverflow`, 20 | description: tag.description || `Questions tagged with ${tag.name}`, 21 | }; 22 | } 23 | 24 | const Page = async ({ params, searchParams }: URLProps) => { 25 | const { userId: clerkId } = auth(); 26 | 27 | const result = await getQuestionsByTagId({ 28 | tagId: params.id, 29 | searchQuery: searchParams.q, 30 | page: searchParams.page ? +searchParams.page : 1, 31 | }); 32 | 33 | return ( 34 | <> 35 |

{result.tagTitle}

36 |
37 | 44 |
45 | 46 |
47 | {result.questions.length > 0 ? ( 48 | result.questions.map((question: any) => ( 49 | 61 | )) 62 | ) : ( 63 | 69 | )} 70 |
71 | 72 |
73 | 77 |
78 | 79 | ); 80 | }; 81 | 82 | export default Page; 83 | -------------------------------------------------------------------------------- /app/(root)/tags/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 |

Tags

7 | 8 |
9 | 10 | 11 |
12 | 13 |
14 | {[...Array(10)].map((_, i) => ( 15 | 16 | ))} 17 |
18 |
19 | ); 20 | }; 21 | 22 | export default Loading; 23 | -------------------------------------------------------------------------------- /app/(root)/tags/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import LocalSearchbar from "@/components/shared/search/LocalSearchbar"; 4 | import Filter from "@/components/shared/Filter"; 5 | import NoResult from "@/components/shared/NoResult"; 6 | import Pagination from "@/components/shared/Pagination"; 7 | 8 | import { getAllTags } from "@/lib/actions/tag.action"; 9 | 10 | import { TagFilters } from "@/constants/filters"; 11 | 12 | import type { SearchParamsProps } from "@/types"; 13 | import type { Metadata } from "next"; 14 | 15 | export const metadata: Metadata = { 16 | title: "Tags — DevOverflow", 17 | }; 18 | 19 | const Page = async ({ searchParams }: SearchParamsProps) => { 20 | const result = await getAllTags({ 21 | searchQuery: searchParams.q, 22 | filter: searchParams.filter, 23 | page: searchParams.page ? +searchParams.page : 1, 24 | }); 25 | 26 | return ( 27 | <> 28 |

All Tags

29 | 30 |
31 | 38 | 39 | 43 |
44 | 45 |
46 | {result.tags.length > 0 ? ( 47 | result.tags.map((tag: any) => ( 48 | 53 |
54 |
55 |

56 | {tag.name} 57 |

58 |
59 | 60 | {tag.description && ( 61 |

62 | {tag.description} 63 |

64 | )} 65 | 66 |

67 | 68 | {tag.questions.length}+ 69 | {" "} 70 | Questions 71 |

72 |
73 | 74 | )) 75 | ) : ( 76 | 84 | )} 85 |
86 | 87 |
88 | 92 |
93 | 94 | ); 95 | }; 96 | 97 | export default Page; 98 | -------------------------------------------------------------------------------- /app/api/openai/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | const config = { 4 | apiKey: process.env.OPENAI_API_KEY as string, 5 | apiHost: "https://api.openai.com/v1/chat/completions", 6 | systemContent: 7 | "You are a knowlegeable assistant that provides quality information.", 8 | userContent: (question: string) => `Tell me ${question}`, 9 | }; 10 | 11 | export const POST = async (request: Request) => { 12 | const { question } = await request.json(); 13 | 14 | try { 15 | const response = await fetch(config.apiHost, { 16 | method: "POST", 17 | headers: { 18 | "Content-Type": "application/json", 19 | Authorization: `Bearer ${config.apiKey}}`, 20 | }, 21 | body: JSON.stringify({ 22 | model: "gpt-3.5-turbo", 23 | messages: [ 24 | { 25 | role: "system", 26 | content: config.systemContent, 27 | }, 28 | { 29 | role: "user", 30 | content: config.userContent(question), 31 | }, 32 | ], 33 | }), 34 | }); 35 | 36 | const responseData = await response.json(); 37 | const reply = responseData.choices[0].message.content; 38 | 39 | return NextResponse.json({ reply }); 40 | } catch (error: any) { 41 | return NextResponse.json({ error: error.message }); 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /app/api/rapidapi/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | const config = { 4 | apiKey: process.env.RAPIDAPI_API_KEY as string, 5 | apiHost: "https://jsearch.p.rapidapi.com", 6 | }; 7 | 8 | export const POST = async (request: Request) => { 9 | const { 10 | page = 1, 11 | pageSize = 1, 12 | filter = "us", 13 | searchQuery = "Software Engineer", 14 | } = await request.json(); 15 | 16 | try { 17 | const response = await fetch( 18 | `${config.apiHost}/search?query=${searchQuery}&location=${filter}&page=${page}&num_pages=${pageSize}`, 19 | { 20 | method: "GET", 21 | headers: { 22 | "X-RapidAPI-Key": config.apiKey, 23 | "X-RapidAPI-Host": config.apiHost, 24 | }, 25 | } 26 | ); 27 | 28 | const responseData = await response.json(); 29 | const result = responseData.result; 30 | 31 | return NextResponse.json({ result }); 32 | } catch (error: any) { 33 | return NextResponse.json({ error: error.message }); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /app/api/webhook/clerk/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { NextResponse } from "next/server"; 3 | import { headers } from "next/headers"; 4 | 5 | import { Webhook } from "svix"; 6 | import { WebhookEvent } from "@clerk/nextjs/server"; 7 | 8 | import { createUser, deleteUser, updateUser } from "@/lib/actions/user.action"; 9 | 10 | export async function POST(req: Request) { 11 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook 12 | const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET; 13 | 14 | if (!WEBHOOK_SECRET) { 15 | throw new Error( 16 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local" 17 | ); 18 | } 19 | 20 | // Get the headers 21 | const headerPayload = headers(); 22 | const svixId = headerPayload.get("svix-id"); 23 | const svixTimestamp = headerPayload.get("svix-timestamp"); 24 | const svixSignature = headerPayload.get("svix-signature"); 25 | 26 | // If there are no headers, error out 27 | if (!svixId || !svixTimestamp || !svixSignature) { 28 | return new Response("Error occured -- no svix headers", { 29 | status: 400, 30 | }); 31 | } 32 | 33 | // Get the body 34 | const payload = await req.json(); 35 | const body = JSON.stringify(payload); 36 | 37 | // Create a new SVIX instance with your secret. 38 | const wh = new Webhook(WEBHOOK_SECRET); 39 | 40 | let evt: WebhookEvent; 41 | 42 | // Verify the payload with the headers 43 | try { 44 | evt = wh.verify(body, { 45 | "svix-id": svixId, 46 | "svix-timestamp": svixTimestamp, 47 | "svix-signature": svixSignature, 48 | }) as WebhookEvent; 49 | } catch (err) { 50 | console.error("Error verifying webhook:", err); 51 | return new Response("Error occured", { 52 | status: 400, 53 | }); 54 | } 55 | 56 | // Get the ID and type 57 | const eventType = evt.type; 58 | 59 | if (eventType === "user.created") { 60 | const { id, email_addresses, image_url, username, first_name, last_name } = 61 | evt.data; 62 | 63 | const parts = email_addresses[0].email_address.split("@"); 64 | 65 | // create a new user in database 66 | const mongoUser = await createUser({ 67 | clerkId: id, 68 | name: `${first_name}${last_name ? ` ${last_name}` : ""}`, 69 | username: username || `${parts[0]}-${parts[1].split(".")[0]}`, 70 | email: email_addresses[0].email_address, 71 | picture: image_url, 72 | }); 73 | 74 | console.log(mongoUser); 75 | 76 | return NextResponse.json({ message: "User created", user: mongoUser }); 77 | } 78 | 79 | if (eventType === "user.updated") { 80 | const { id, email_addresses, image_url, username, first_name, last_name } = 81 | evt.data; 82 | 83 | // create a new user in database 84 | const mongoUser = await updateUser({ 85 | clerkId: id, 86 | updateData: { 87 | name: `${first_name}${last_name ? ` ${last_name}` : ""}`, 88 | username: username!, 89 | email: email_addresses[0].email_address, 90 | picture: image_url, 91 | }, 92 | path: `/profile/${id}`, 93 | }); 94 | 95 | return NextResponse.json({ message: "User updated", user: mongoUser }); 96 | } 97 | 98 | if (eventType === "user.deleted") { 99 | const { id } = evt.data; 100 | 101 | const deletedUser = await deleteUser({ 102 | clerkId: id!, 103 | }); 104 | 105 | return NextResponse.json({ message: "User deleted", user: deletedUser }); 106 | } 107 | 108 | return new Response("", { status: 201 }); 109 | } 110 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ladunjexa/nextjs14-devoverflow/00823bb5cb120854811abb7fd4696c583136505a/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | 5 | // eslint-disable-next-line camelcase 6 | import { Inter, Space_Grotesk } from "next/font/google"; 7 | 8 | import { ThemeProvider } from "@/context/ThemeProvider"; 9 | 10 | import type { Metadata } from "next"; 11 | 12 | import "./globals.css"; 13 | 14 | const inter = Inter({ 15 | subsets: ["latin"], 16 | weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], 17 | variable: "--font-inter", 18 | }); 19 | 20 | const spaceGrotesk = Space_Grotesk({ 21 | subsets: ["latin"], 22 | weight: ["300", "400", "500", "600", "700"], 23 | variable: "--font-spaceGrotesk", 24 | }); 25 | 26 | export const metadata: Metadata = { 27 | title: "DevOverflow", 28 | description: 29 | "A community-driven platform for asking and answering questions about software development. Get help, share knowledge, and collaborate with developers from around the world. Explore topics in web development, mobile app development, game development, algorithms, data structures, and more.", 30 | icons: { 31 | icon: "/assets/images/site-logo.svg", 32 | }, 33 | }; 34 | 35 | export default function RootLayout({ 36 | children, 37 | }: { 38 | children: React.ReactNode; 39 | }) { 40 | return ( 41 | 42 | 43 | 51 | {children} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/cards/AnswerCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { SignedIn } from "@clerk/nextjs"; 4 | 5 | import Metric from "@/components/shared/Metric"; 6 | import EditDeleteAction from "@/components/shared/EditDeleteAction"; 7 | 8 | import { getFormattedNumber, getTimestamp } from "@/lib/utils"; 9 | 10 | interface Props { 11 | clerkId?: string | null; 12 | _id: string; 13 | question: { 14 | _id: string; 15 | title: string; 16 | }; 17 | author: { 18 | _id: string; 19 | clerkId: string; 20 | name: string; 21 | picture: string; 22 | }; 23 | upvotes: number; 24 | createdAt: Date; 25 | } 26 | 27 | const AnswerCard = ({ 28 | clerkId, 29 | _id, 30 | question, 31 | author, 32 | upvotes, 33 | createdAt, 34 | }: Props) => { 35 | const showActionButtons = clerkId && clerkId === author.clerkId; 36 | 37 | return ( 38 | 42 |
43 |
44 | 45 | {getTimestamp(createdAt)} 46 | 47 |

48 | {question.title} 49 |

50 |
51 | 52 | 53 | {showActionButtons && ( 54 | 55 | )} 56 | 57 |
58 | 59 |
60 | 69 | 70 |
71 | 78 |
79 |
80 | 81 | ); 82 | }; 83 | 84 | export default AnswerCard; 85 | -------------------------------------------------------------------------------- /components/cards/QuestionCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { SignedIn } from "@clerk/nextjs"; 4 | 5 | import RenderTag from "@/components/shared/RenderTag"; 6 | import Metric from "@/components/shared/Metric"; 7 | import EditDeleteAction from "@/components/shared/EditDeleteAction"; 8 | 9 | import { getFormattedNumber, getTimestamp } from "@/lib/utils"; 10 | 11 | interface QuestionProps { 12 | _id: string; 13 | title: string; 14 | tags: Array<{ _id: string; name: string }>; 15 | author: { 16 | _id: string; 17 | name: string; 18 | picture: string; 19 | clerkId: string; 20 | }; 21 | upvotes: string[]; 22 | views: number; 23 | answers: Array; 24 | createdAt: Date; 25 | clerkId?: string | null; 26 | } 27 | 28 | const QuestionCard = ({ 29 | _id, 30 | title, 31 | tags, 32 | author, 33 | upvotes, 34 | views, 35 | answers, 36 | createdAt, 37 | clerkId, 38 | }: QuestionProps) => { 39 | const showActionButtons = clerkId && clerkId === author.clerkId; 40 | 41 | return ( 42 |
43 |
44 |
45 | 46 | {getTimestamp(createdAt)} 47 | 48 | 49 |

50 | {title} 51 |

52 | 53 |
54 | 55 | 56 | {showActionButtons && ( 57 | 58 | )} 59 | 60 |
61 | 62 |
63 | {tags.map((tag) => ( 64 | 65 | ))} 66 |
67 | 68 |
69 | 78 | 79 |
80 | 87 | 94 | 101 |
102 |
103 |
104 | ); 105 | }; 106 | 107 | export default QuestionCard; 108 | -------------------------------------------------------------------------------- /components/cards/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { getTopInteractedTags } from "@/lib/actions/tag.action"; 5 | 6 | import { Badge } from "@/components/ui/badge"; 7 | import RenderTag from "@/components/shared/RenderTag"; 8 | 9 | interface Props { 10 | user: { 11 | _id: string; 12 | clerkId: string; 13 | picture: string; 14 | name: string; 15 | username: string; 16 | }; 17 | } 18 | 19 | const UserCard = async ({ user }: Props) => { 20 | const interactedTags = await getTopInteractedTags({ 21 | userId: user._id, 22 | }); 23 | 24 | return ( 25 | 29 |
30 | User profile picture 37 |
38 |

39 | {user.name} 40 |

41 |

42 | @{user.username} 43 |

44 |
45 | 46 |
47 | {interactedTags.length > 0 ? ( 48 |
49 | {interactedTags.map((tag: any) => ( 50 | 51 | ))} 52 |
53 | ) : ( 54 | No tags yet 55 | )} 56 |
57 |
58 | 59 | ); 60 | }; 61 | 62 | export default UserCard; 63 | -------------------------------------------------------------------------------- /components/jobs/JobBadge.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | import { Badge } from "@/components/ui/badge"; 5 | 6 | const JobBadge = ({ 7 | data, 8 | badgeStyles, 9 | isLocation, 10 | }: { 11 | data: any; 12 | badgeStyles?: string; 13 | isLocation?: boolean; 14 | }) => { 15 | if (isLocation && !data.location) return null; 16 | 17 | const classNames = isLocation 18 | ? "`subtle-regular background-light800_dark300 text-light400_light500 gap-2 rounded-full border-none px-4 py-2" 19 | : "background-light800_dark400 relative h-16 w-16 rounded-lg"; 20 | return ( 21 | 22 | {isLocation ? ( 23 | <> 24 | {data.location} 25 | {data.country && ( 26 | flag 33 | )} 34 | 35 | ) : ( 36 | 37 | logo 43 | 44 | )} 45 | 46 | ); 47 | }; 48 | 49 | export default JobBadge; 50 | -------------------------------------------------------------------------------- /components/shared/AllAnswers.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { SignedIn } from "@clerk/nextjs"; 5 | 6 | import Filter from "@/components/shared/Filter"; 7 | import ParseHTML from "@/components/shared/ParseHTML"; 8 | import Votes from "@/components/shared/Votes"; 9 | import Pagination from "@/components/shared/Pagination"; 10 | import EditDeleteAction from "@/components/shared/EditDeleteAction"; 11 | 12 | import { getAnswers } from "@/lib/actions/answer.action"; 13 | import { getTimestamp } from "@/lib/utils"; 14 | 15 | import { AnswerFilters } from "@/constants/filters"; 16 | 17 | import type { 18 | QuestionId, 19 | UserId, 20 | OptionalPage, 21 | OptionalFilter, 22 | } from "@/lib/actions/shared.types"; 23 | 24 | interface Props extends QuestionId, UserId, OptionalPage, OptionalFilter { 25 | totalAnswers: number; 26 | } 27 | 28 | const AllAnswers = async ({ 29 | userId, 30 | questionId, 31 | totalAnswers, 32 | filter, 33 | page, 34 | }: Props) => { 35 | const result = await getAnswers({ 36 | questionId, 37 | sortBy: filter, 38 | page, 39 | }); 40 | 41 | return ( 42 |
43 |
44 |

{totalAnswers} Answers

45 | 46 |
47 |
48 | {result.answers.map((answer: any) => { 49 | const showActionButtons = 50 | JSON.stringify(userId) === JSON.stringify(answer.author._id); 51 | 52 | return ( 53 |
54 |
55 | 59 | profile 66 |
67 |

68 | {answer.author.name} 69 |

70 |

71 | • answered 72 | {getTimestamp(answer.createdAt)} 73 |

74 |
75 | 76 |
77 | 86 |
87 |
88 | 89 | 90 | 91 | {showActionButtons && ( 92 | 96 | )} 97 | 98 |
99 | ); 100 | })} 101 |
102 | 103 |
104 | 105 |
106 |
107 | ); 108 | }; 109 | 110 | export default AllAnswers; 111 | -------------------------------------------------------------------------------- /components/shared/AnswersTab.tsx: -------------------------------------------------------------------------------- 1 | import Pagination from "@/components/shared/Pagination"; 2 | import AnswerCard from "@/components/cards/AnswerCard"; 3 | 4 | import { getUserAnswers } from "@/lib/actions/user.action"; 5 | 6 | import type { UserId } from "@/lib/actions/shared.types"; 7 | import type { SearchParamsProps } from "@/types"; 8 | 9 | interface Props extends SearchParamsProps, UserId { 10 | clerkId?: string | null; 11 | } 12 | const AnswersTab = async ({ searchParams, userId, clerkId }: Props) => { 13 | const result = await getUserAnswers({ 14 | userId, 15 | page: searchParams.page ? +searchParams.page : 1, 16 | }); 17 | 18 | return ( 19 | <> 20 | {result.answers.map((answer: any) => ( 21 | 30 | ))} 31 | 32 |
33 | 37 |
38 | 39 | ); 40 | }; 41 | 42 | export default AnswersTab; 43 | -------------------------------------------------------------------------------- /components/shared/EditDeleteAction.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | 6 | import { deleteAnswer } from "@/lib/actions/answer.action"; 7 | import { deleteQuestion } from "@/lib/actions/question.action"; 8 | 9 | interface Props { 10 | type: string; 11 | itemId: string; 12 | } 13 | const EditDeleteAction = ({ type, itemId }: Props) => { 14 | const pathname = usePathname(); 15 | const router = useRouter(); 16 | 17 | const handleEdit = () => { 18 | if (type === "Question") { 19 | router.push(`/question/edit/${JSON.parse(itemId)}`); 20 | } else if (type === "Answer") { 21 | router.push(`/edit-answer/${JSON.parse(itemId)}`); 22 | } 23 | }; 24 | 25 | const handleDelete = async () => { 26 | if (type === "Question") { 27 | await deleteQuestion({ 28 | questionId: JSON.parse(itemId), 29 | path: pathname, 30 | isQuestionPath: pathname === `/question/${JSON.parse(itemId)}`, 31 | }); 32 | } else if (type === "Answer") { 33 | await deleteAnswer({ answerId: JSON.parse(itemId), path: pathname }); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 | Edit 47 | 48 | Delete 56 |
57 | ); 58 | }; 59 | 60 | export default EditDeleteAction; 61 | -------------------------------------------------------------------------------- /components/shared/Filter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useRouter, useSearchParams } from "next/navigation"; 5 | 6 | import { 7 | Select, 8 | SelectContent, 9 | SelectGroup, 10 | SelectItem, 11 | SelectTrigger, 12 | SelectValue, 13 | } from "@/components/ui/select"; 14 | 15 | import { formUrlQuery } from "@/lib/utils"; 16 | 17 | import type { FilterProps } from "@/types"; 18 | 19 | interface Props { 20 | filters: FilterProps[]; 21 | otherClasses?: string; 22 | containerClasses?: string; 23 | jobFilter?: boolean; 24 | } 25 | const Filter = ({ 26 | filters, 27 | otherClasses, 28 | containerClasses, 29 | jobFilter = false, 30 | }: Props) => { 31 | const searchParams = useSearchParams(); 32 | const router = useRouter(); 33 | 34 | const searchParamKey = jobFilter ? "location" : "filter"; 35 | const paramFilter = searchParams.get(searchParamKey); 36 | 37 | const handleUpdateParams = (value: string) => { 38 | const newUrl = formUrlQuery({ 39 | params: searchParams.toString(), 40 | key: searchParamKey, 41 | value: jobFilter ? value.toLowerCase() : value, 42 | }); 43 | 44 | router.push(newUrl, { scroll: false }); 45 | }; 46 | 47 | return ( 48 |
49 | 89 |
90 | ); 91 | }; 92 | 93 | export default Filter; 94 | -------------------------------------------------------------------------------- /components/shared/Filters.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useRouter, useSearchParams } from "next/navigation"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import Switcher from "@/components/shared/Switcher"; 8 | 9 | import { formUrlQuery } from "@/lib/utils"; 10 | 11 | import type { FilterProps } from "@/types"; 12 | 13 | const Filters = ({ 14 | filters, 15 | jobFilter = false, 16 | }: { 17 | filters: FilterProps[]; 18 | jobFilter?: boolean; 19 | }) => { 20 | const searchParams = useSearchParams(); 21 | const router = useRouter(); 22 | 23 | const [active, setActive] = useState(""); 24 | 25 | const handleTypeClick = (item: string) => { 26 | if (active === item) { 27 | setActive(""); 28 | 29 | const newUrl = formUrlQuery({ 30 | params: searchParams.toString(), 31 | key: "filter", 32 | value: null, 33 | }); 34 | 35 | router.push(newUrl, { scroll: false }); 36 | } else { 37 | setActive(item); 38 | 39 | const newUrl = formUrlQuery({ 40 | params: searchParams.toString(), 41 | key: "filter", 42 | value: item.toLowerCase(), 43 | }); 44 | 45 | router.push(newUrl, { scroll: false }); 46 | } 47 | }; 48 | 49 | return ( 50 |
55 | {filters.map((filter, index) => ( 56 | 68 | ))} 69 | 70 | {jobFilter && ( 71 |
72 | 73 | 74 | 75 |
76 | )} 77 |
78 | ); 79 | }; 80 | 81 | export default Filters; 82 | -------------------------------------------------------------------------------- /components/shared/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | import { SignedOut, useAuth } from "@clerk/nextjs"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | 11 | import { sidebarLinks } from "@/constants"; 12 | 13 | const LeftSidebar = () => { 14 | const { userId } = useAuth(); 15 | const pathname = usePathname(); 16 | 17 | return ( 18 |
19 |
20 | {sidebarLinks.map((link) => { 21 | const isActive = 22 | (pathname.includes(link.route) && link.route.length > 1) || 23 | pathname === link.route; 24 | 25 | if (link.route === "/profile") { 26 | if (userId) { 27 | link.route = `${link.route}/${userId}`; 28 | } else { 29 | return null; 30 | } 31 | } 32 | 33 | return ( 34 | 43 | {link.label} 50 |

55 | {link.label} 56 |

57 | 58 | ); 59 | })} 60 |
61 | 62 | 63 |
64 | 65 | 77 | 78 | 79 | 80 | 90 | 91 |
92 |
93 |
94 | ); 95 | }; 96 | 97 | export default LeftSidebar; 98 | -------------------------------------------------------------------------------- /components/shared/Metric.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | interface MetricProps { 5 | imgUrl: string; 6 | alt: string; 7 | value: string | number; 8 | title?: string; 9 | href?: string; 10 | textStyles?: string; 11 | isAuthor?: boolean; 12 | } 13 | 14 | const Metric = ({ 15 | imgUrl, 16 | alt, 17 | value, 18 | title, 19 | href, 20 | textStyles, 21 | isAuthor, 22 | }: MetricProps) => { 23 | const metricContent = ( 24 | <> 25 | {alt} 32 |

33 | {value} 34 | {title && ( 35 | 40 | {title} 41 | 42 | )} 43 |

44 | 45 | ); 46 | 47 | if (href) { 48 | return ( 49 | 50 | {metricContent} 51 | 52 | ); 53 | } 54 | 55 | return
{metricContent}
; 56 | }; 57 | 58 | export default Metric; 59 | -------------------------------------------------------------------------------- /components/shared/NoResult.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { Button } from "@/components/ui/button"; 5 | 6 | interface Props { 7 | title: string; 8 | description: string; 9 | link: string; 10 | linkTitle: string; 11 | } 12 | 13 | const NoResult = ({ title, description, link, linkTitle }: Props) => { 14 | return ( 15 |
16 | No result illustration 23 | No result illustration 30 | 31 |

{title}

32 |

33 | {description} 34 |

35 | 36 | 37 | 40 | 41 |
42 | ); 43 | }; 44 | 45 | export default NoResult; 46 | -------------------------------------------------------------------------------- /components/shared/Pagination.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { useRouter, useSearchParams } from "next/navigation"; 5 | 6 | import { Button } from "../ui/button"; 7 | 8 | import { formUrlQuery } from "@/lib/utils"; 9 | 10 | interface Props { 11 | pageNumber: number; 12 | isNext: boolean; 13 | } 14 | 15 | const Pagination = ({ pageNumber, isNext }: Props) => { 16 | const router = useRouter(); 17 | const searchParams = useSearchParams(); 18 | 19 | const handleNavigation = (direction: string) => { 20 | const nextPageNumber = 21 | direction === "prev" ? pageNumber - 1 : pageNumber + 1; 22 | 23 | const newUrl = formUrlQuery({ 24 | params: searchParams.toString(), 25 | key: "page", 26 | value: nextPageNumber.toString(), 27 | }); 28 | 29 | router.push(newUrl); 30 | }; 31 | 32 | if (!isNext && pageNumber === 1) return null; // hide pagination if there is only one page 33 | 34 | return ( 35 |
36 | 43 |
44 |

{pageNumber}

45 |
{" "} 46 | 53 |
54 | ); 55 | }; 56 | 57 | export default Pagination; 58 | -------------------------------------------------------------------------------- /components/shared/ParseHTML.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect } from "react"; 4 | 5 | import Prism from "prismjs"; 6 | import parse from "html-react-parser"; 7 | 8 | import "prismjs/components/prism-python"; 9 | import "prismjs/components/prism-java"; 10 | import "prismjs/components/prism-c"; 11 | import "prismjs/components/prism-cpp"; 12 | import "prismjs/components/prism-csharp"; 13 | import "prismjs/components/prism-aspnet"; 14 | import "prismjs/components/prism-sass"; 15 | import "prismjs/components/prism-jsx"; 16 | import "prismjs/components/prism-typescript"; 17 | import "prismjs/components/prism-solidity"; 18 | import "prismjs/components/prism-json"; 19 | import "prismjs/components/prism-dart"; 20 | import "prismjs/components/prism-ruby"; 21 | import "prismjs/components/prism-rust"; 22 | import "prismjs/components/prism-r"; 23 | import "prismjs/components/prism-kotlin"; 24 | import "prismjs/components/prism-go"; 25 | import "prismjs/components/prism-bash"; 26 | import "prismjs/components/prism-sql"; 27 | import "prismjs/components/prism-mongodb"; 28 | import "prismjs/plugins/line-numbers/prism-line-numbers.js"; 29 | import "prismjs/plugins/line-numbers/prism-line-numbers.css"; 30 | 31 | interface Props { 32 | data: string; 33 | } 34 | 35 | const ParseHTML = ({ data }: Props) => { 36 | useEffect(() => { 37 | Prism.highlightAll(); 38 | }, []); 39 | 40 | return
{parse(data)}
; 41 | }; 42 | 43 | export default ParseHTML; 44 | -------------------------------------------------------------------------------- /components/shared/ProfileLink.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | interface Props { 5 | imgUrl: string; 6 | title: string; 7 | href?: string; 8 | } 9 | const ProfileLink = ({ imgUrl, title, href }: Props) => { 10 | return ( 11 |
12 | icon 13 | 14 | {href ? ( 15 | 20 | {title} 21 | 22 | ) : ( 23 |

{title}

24 | )} 25 |
26 | ); 27 | }; 28 | 29 | export default ProfileLink; 30 | -------------------------------------------------------------------------------- /components/shared/QuestionsTab.tsx: -------------------------------------------------------------------------------- 1 | import Pagination from "@/components/shared/Pagination"; 2 | import QuestionCard from "@/components/cards/QuestionCard"; 3 | 4 | import { getUserQuestions } from "@/lib/actions/user.action"; 5 | 6 | import type { UserId } from "@/lib/actions/shared.types"; 7 | import type { SearchParamsProps } from "@/types"; 8 | 9 | interface Props extends SearchParamsProps, UserId { 10 | clerkId?: string | null; 11 | } 12 | 13 | const QuestionsTab = async ({ searchParams, userId, clerkId }: Props) => { 14 | const result = await getUserQuestions({ 15 | userId, 16 | page: searchParams.page ? +searchParams.page : 1, 17 | }); 18 | 19 | return ( 20 | <> 21 | {result.questions.map((question) => ( 22 | 34 | ))} 35 | 36 |
37 | 41 |
42 | 43 | ); 44 | }; 45 | 46 | export default QuestionsTab; 47 | -------------------------------------------------------------------------------- /components/shared/RenderTag.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { Badge } from "@/components/ui/badge"; 4 | 5 | interface Props { 6 | _id: string; 7 | name: string; 8 | totalQuestions?: number; 9 | showCount?: boolean; 10 | } 11 | 12 | const RenderTag = ({ _id, name, totalQuestions, showCount }: Props) => { 13 | return ( 14 | 15 | 16 | {name} 17 | 18 | {showCount && ( 19 |

{totalQuestions}

20 | )} 21 | 22 | ); 23 | }; 24 | 25 | export default RenderTag; 26 | -------------------------------------------------------------------------------- /components/shared/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { getHotQuestions } from "@/lib/actions/question.action"; 5 | import { getTopPopularTags } from "@/lib/actions/tag.action"; 6 | 7 | import RenderTag from "@/components/shared/RenderTag"; 8 | 9 | const RightSidebar = async () => { 10 | const hotQuestions = await getHotQuestions(); 11 | const popularTags = await getTopPopularTags(); 12 | 13 | return ( 14 |
15 |
16 |

Top Questions

17 |
18 | {hotQuestions.map((question: any) => ( 19 | 24 |

25 | {question.title} 26 |

27 | chevron right 34 | 35 | ))} 36 |
37 |
38 |
39 |

Popular Tags

40 |
41 | {popularTags.map((tag: any) => ( 42 | 49 | ))} 50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | export default RightSidebar; 57 | -------------------------------------------------------------------------------- /components/shared/Stats.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | import { getFormattedNumber } from "@/lib/utils"; 4 | 5 | import type { BadgeCounts } from "@/types"; 6 | 7 | interface Props { 8 | totalQuestions: number; 9 | totalAnswers: number; 10 | badges: BadgeCounts; 11 | reputation: number; 12 | } 13 | 14 | interface StatsCardProps { 15 | imgUrl: string; 16 | value: number; 17 | title: string; 18 | } 19 | 20 | const StatsCard = ({ imgUrl, value, title }: StatsCardProps) => { 21 | return ( 22 |
23 | {title} 24 |
25 |

{value}

26 |

{title}

27 |
28 |
29 | ); 30 | }; 31 | 32 | const Stats = ({ totalQuestions, totalAnswers, badges, reputation }: Props) => { 33 | return ( 34 |
35 |

36 | Stats - {reputation} 37 |

38 | 39 |
40 |
41 |
42 |

43 | {getFormattedNumber(totalQuestions)} 44 |

45 |

Questions

46 |
47 |
48 |

49 | {getFormattedNumber(totalAnswers)} 50 |

51 |

Answers

52 |
53 |
54 | 55 | 60 | 61 | 66 | 67 | 72 |
73 |
74 | ); 75 | }; 76 | 77 | export default Stats; 78 | -------------------------------------------------------------------------------- /components/shared/Switcher.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | 5 | import { Label } from "@/components/ui/label"; 6 | import { Switch } from "@/components/ui/switch"; 7 | 8 | import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; 9 | 10 | interface Props { 11 | query: string; 12 | label: string; 13 | } 14 | 15 | const Switcher = ({ query, label }: Props) => { 16 | const searchParams = useSearchParams(); 17 | const router = useRouter(); 18 | 19 | const paramFilter = searchParams.get(query); 20 | 21 | const handleUpdateParams = (value: string) => { 22 | let newUrl; 23 | 24 | if (!value) { 25 | newUrl = removeKeysFromQuery({ 26 | params: searchParams.toString(), 27 | keysToRemove: [query], 28 | }); 29 | } else { 30 | newUrl = formUrlQuery({ 31 | params: searchParams.toString(), 32 | key: query, 33 | value, 34 | }); 35 | } 36 | 37 | router.push(newUrl, { scroll: false }); 38 | }; 39 | 40 | return ( 41 | <> 42 | 49 | 52 | 53 | ); 54 | }; 55 | 56 | export default Switcher; 57 | -------------------------------------------------------------------------------- /components/shared/navbar/Mobile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { usePathname } from "next/navigation"; 6 | 7 | import { SignedOut } from "@clerk/nextjs"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Sheet, 12 | SheetClose, 13 | SheetContent, 14 | SheetTrigger, 15 | } from "@/components/ui/sheet"; 16 | 17 | import { sidebarLinks } from "@/constants"; 18 | 19 | const NavContent = () => { 20 | const pathname = usePathname(); 21 | 22 | return ( 23 |
24 | {sidebarLinks.map((link) => { 25 | const isActive: boolean = 26 | (pathname.includes(link.route) && link.route.length > 1) || 27 | pathname === link.route; 28 | 29 | return ( 30 | 31 | 39 | {link.label} 46 |

47 | {link.label} 48 |

49 | 50 |
51 | ); 52 | })} 53 |
54 | ); 55 | }; 56 | 57 | const Mobile = () => { 58 | return ( 59 | 60 | 61 | Menu 68 | 69 | 73 | 74 | DevOverflow 80 | 81 |

82 | Dev Overflow 83 |

84 | 85 |
86 | 87 | 88 | 89 | 90 | 91 |
92 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 102 | 105 | 106 | 107 |
108 |
109 |
110 |
111 |
112 | ); 113 | }; 114 | 115 | export default Mobile; 116 | -------------------------------------------------------------------------------- /components/shared/navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | 4 | import { SignedIn, UserButton } from "@clerk/nextjs"; 5 | 6 | import Theme from "@/components/shared/navbar/Theme"; 7 | import Mobile from "@/components/shared/navbar/Mobile"; 8 | import GlobalSearch from "@/components/shared/search/GlobalSearch"; 9 | 10 | const Navbar = () => { 11 | return ( 12 | 46 | ); 47 | }; 48 | 49 | export default Navbar; 50 | -------------------------------------------------------------------------------- /components/shared/navbar/Theme.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | import { useTheme } from "@/context/ThemeProvider"; 6 | 7 | import { 8 | Menubar, 9 | MenubarContent, 10 | MenubarItem, 11 | MenubarMenu, 12 | MenubarTrigger, 13 | } from "@/components/ui/menubar"; 14 | 15 | import { themes } from "@/constants"; 16 | 17 | const Theme = () => { 18 | const { mode, setMode } = useTheme(); 19 | 20 | return ( 21 | 22 | 23 | 24 | {mode === "light" ? ( 25 | sun 32 | ) : ( 33 | moon 40 | )} 41 | 42 | 43 | {themes.map((theme) => ( 44 | { 48 | setMode(theme.value); 49 | 50 | if (theme.value !== "system") { 51 | localStorage.theme = theme.value; 52 | } else { 53 | localStorage.removeItem("theme"); 54 | } 55 | }} 56 | > 57 | {theme.value} 64 |

71 | {theme.label} 72 |

73 |
74 | ))} 75 |
76 |
77 |
78 | ); 79 | }; 80 | 81 | export default Theme; 82 | -------------------------------------------------------------------------------- /components/shared/search/GlobalFilters.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useState } from "react"; 3 | import { useRouter, useSearchParams } from "next/navigation"; 4 | 5 | import { GlobalSearchFilters } from "@/constants/filters"; 6 | 7 | import { formUrlQuery } from "@/lib/utils"; 8 | 9 | const GlobalFilters = () => { 10 | const router = useRouter(); 11 | const searchParams = useSearchParams(); 12 | 13 | const typeParams = searchParams.get("type"); 14 | 15 | const [active, setActive] = useState(typeParams || ""); 16 | 17 | const handleTypeClick = (item: string) => { 18 | if (active === item) { 19 | setActive(""); 20 | const newUrl = formUrlQuery({ 21 | params: searchParams.toString(), 22 | key: "type", 23 | value: null, 24 | }); 25 | 26 | router.push(newUrl, { scroll: false }); 27 | } else { 28 | setActive(item); 29 | const newUrl = formUrlQuery({ 30 | params: searchParams.toString(), 31 | key: "type", 32 | value: item.toLowerCase(), 33 | }); 34 | 35 | router.push(newUrl, { scroll: false }); 36 | } 37 | }; 38 | 39 | return ( 40 |
41 |

Type:

42 |
43 | {GlobalSearchFilters.map((filter) => ( 44 | 56 | ))} 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default GlobalFilters; 63 | -------------------------------------------------------------------------------- /components/shared/search/GlobalResult.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import Link from "next/link"; 5 | import Image from "next/image"; 6 | import { useSearchParams } from "next/navigation"; 7 | 8 | import { ReloadIcon } from "@radix-ui/react-icons"; 9 | 10 | import GlobalFilters from "@/components/shared/search/GlobalFilters"; 11 | 12 | import { globalSearch } from "@/lib/actions/general.action"; 13 | 14 | const GlobalResult = () => { 15 | const searchParams = useSearchParams(); 16 | 17 | const [isLoading, setIsLoading] = useState(false); 18 | const [result, setResult] = useState([]); 19 | 20 | const global = searchParams.get("global"); 21 | const type = searchParams.get("type"); 22 | 23 | useEffect(() => { 24 | const fetchResult = async () => { 25 | setResult([]); 26 | setIsLoading(true); 27 | 28 | try { 29 | const response = await globalSearch({ query: global, type }); 30 | 31 | setResult(JSON.parse(response)); 32 | } catch (error) { 33 | console.error(error); 34 | throw error; 35 | } finally { 36 | setIsLoading(false); 37 | } 38 | }; 39 | 40 | if (global) { 41 | fetchResult(); 42 | } 43 | }, [global, type]); 44 | 45 | const renderLink = (type: string, id: string) => { 46 | switch (type) { 47 | case "question": 48 | case "answer": 49 | return `/question/${Array.isArray(id) ? id[0] : id}`; 50 | // case "answer": 51 | // return `/question/${id[0]}#${id[1]}`; 52 | case "user": 53 | return `/profile/${id}`; 54 | case "tag": 55 | return `/tags/${id}`; 56 | default: 57 | return "/"; 58 | } 59 | }; 60 | 61 | return ( 62 |
63 | 64 |
65 |
66 |

67 | Top Match 68 |

69 |
70 | 71 | {isLoading ? ( 72 |
73 | 74 |

75 | Browsing the entire database... 76 |

77 |
78 | ) : ( 79 |
80 | {result.length > 0 ? ( 81 | result.map((item: any, index: number) => ( 82 | 87 | tag 94 | 95 |
96 |

97 | {item.title} 98 |

99 |

100 | {item.type} 101 |

102 |
103 | 104 | )) 105 | ) : ( 106 |
107 |
108 |

109 | No results found. 110 |

111 |
112 |
113 | )} 114 |
115 | )} 116 |
117 | ); 118 | }; 119 | 120 | export default GlobalResult; 121 | -------------------------------------------------------------------------------- /components/shared/search/GlobalSearch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useRef } from "react"; 4 | import Image from "next/image"; 5 | import { useRouter, usePathname, useSearchParams } from "next/navigation"; 6 | 7 | import { Input } from "@/components/ui/input"; 8 | import GlobalResult from "@/components/shared/search/GlobalResult"; 9 | 10 | import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; 11 | 12 | const GlobalSearch = () => { 13 | const router = useRouter(); 14 | const pathname = usePathname(); 15 | const searchParams = useSearchParams(); 16 | const searchContainerRef = useRef(null); 17 | 18 | const query = searchParams.get("q"); 19 | 20 | const [search, setSearch] = useState(query || ""); 21 | const [isOpen, setIsOpen] = useState(false); 22 | 23 | useEffect(() => { 24 | const handleOutsideClick = (e: any) => { 25 | if ( 26 | searchContainerRef.current && 27 | !searchContainerRef.current.contains(e.target) 28 | ) { 29 | setIsOpen(false); 30 | setSearch(""); 31 | } 32 | }; 33 | 34 | setIsOpen(false); 35 | 36 | document.addEventListener("click", handleOutsideClick); 37 | 38 | return () => { 39 | document.removeEventListener("click", handleOutsideClick); 40 | }; 41 | }, [pathname]); 42 | 43 | useEffect(() => { 44 | const delayDebounceFn = setTimeout(() => { 45 | if (search) { 46 | const newUrl = formUrlQuery({ 47 | params: searchParams.toString(), 48 | key: "global", 49 | value: search, 50 | }); 51 | 52 | router.push(newUrl, { scroll: false }); 53 | } else { 54 | if (!query) { 55 | const newUrl = removeKeysFromQuery({ 56 | params: searchParams.toString(), 57 | keysToRemove: ["global", "type"], 58 | }); 59 | 60 | router.push(newUrl, { scroll: false }); 61 | } 62 | } 63 | }, 300); 64 | 65 | return () => clearTimeout(delayDebounceFn); 66 | }, [search, pathname, router, searchParams, query]); 67 | 68 | return ( 69 |
73 |
74 | search 81 | 82 | { 87 | setSearch(e.target.value); 88 | 89 | if (!isOpen) setIsOpen(true); 90 | 91 | if (e.target.value === "" && isOpen) setIsOpen(false); 92 | }} 93 | className="text-dark400_light700 paragraph-regular no-focus placeholder border-none bg-transparent shadow-none outline-none" 94 | /> 95 |
96 | 97 | {isOpen && } 98 |
99 | ); 100 | }; 101 | 102 | export default GlobalSearch; 103 | -------------------------------------------------------------------------------- /components/shared/search/LocalSearchbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import Image from "next/image"; 5 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 6 | 7 | import { Input } from "@/components/ui/input"; 8 | 9 | import { formUrlQuery, removeKeysFromQuery } from "@/lib/utils"; 10 | 11 | interface CustomInputProps { 12 | route: string; 13 | iconPosition: string; 14 | imgSrc: string; 15 | placeholder: string; 16 | otherClasses: string; 17 | } 18 | 19 | const LocalSearchbar = ({ 20 | route, 21 | iconPosition, 22 | imgSrc, 23 | placeholder, 24 | otherClasses, 25 | }: CustomInputProps) => { 26 | const router = useRouter(); 27 | const pathname = usePathname(); 28 | const searchParams = useSearchParams(); 29 | 30 | const query = searchParams.get("q"); 31 | 32 | const [search, setSearch] = useState(query || ""); 33 | 34 | useEffect(() => { 35 | const delayDebounceFn = setTimeout(() => { 36 | if (search) { 37 | const newUrl = formUrlQuery({ 38 | params: searchParams.toString(), 39 | key: "q", 40 | value: search, 41 | }); 42 | 43 | router.push(newUrl, { scroll: false }); 44 | } else { 45 | if (pathname === route) { 46 | const newUrl = removeKeysFromQuery({ 47 | params: searchParams.toString(), 48 | keysToRemove: ["q"], 49 | }); 50 | 51 | router.push(newUrl, { scroll: false }); 52 | } 53 | } 54 | }, 300); 55 | 56 | return () => clearTimeout(delayDebounceFn); 57 | }, [search, route, pathname, router, searchParams, query]); 58 | 59 | return ( 60 |
65 | search icon 72 | 73 | setSearch(e.target.value)} 78 | className="text-dark400_light700 paragraph-regular no-focus placeholder text-dark400_light700 border-none bg-transparent shadow-none outline-none" 79 | /> 80 |
81 | ); 82 | }; 83 | 84 | export default LocalSearchbar; 85 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "focus:ring-ring inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary text-primary-foreground hover:bg-primary/80 border-transparent", 13 | secondary: 14 | "bg-secondary text-secondary-foreground hover:bg-secondary/80 border-transparent", 15 | destructive: 16 | "bg-destructive text-destructive-foreground hover:bg-destructive/80 border-transparent", 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 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "ring-offset-background focus-visible:ring-ring 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-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border-input bg-background hover:bg-accent hover:text-accent-foreground border", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ); 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button"; 45 | return ( 46 | 51 | ); 52 | } 53 | ); 54 | Button.displayName = "Button"; 55 | 56 | export { Button, buttonVariants }; 57 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils"; 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ); 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )); 24 | Label.displayName = LabelPrimitive.Root.displayName; 25 | 26 | export { Label }; 27 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | function Skeleton({ 5 | className, 6 | ...props 7 | }: React.HTMLAttributes) { 8 | return ( 9 |
16 | ); 17 | } 18 | 19 | export { Skeleton }; 20 | -------------------------------------------------------------------------------- /components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )); 27 | Switch.displayName = SwitchPrimitives.Root.displayName; 28 | 29 | export { Switch }; 30 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Tabs = TabsPrimitive.Root; 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )); 23 | TabsList.displayName = TabsPrimitive.List.displayName; 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )); 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )); 53 | TabsContent.displayName = TabsPrimitive.Content.displayName; 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 56 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |