├── .eslintrc.json ├── public ├── cry.png ├── icon.png ├── img1.png ├── img2.png ├── img3.png ├── img4.png ├── love.png ├── robot.png ├── correct.wav ├── finish.mp3 ├── question.png ├── search.png ├── depression.png ├── incorrect.wav ├── robot-error.png ├── js.svg ├── heart.svg ├── next.svg ├── php.svg ├── go.svg └── java.svg ├── src ├── app │ ├── favicon.ico │ ├── (main) │ │ ├── (shop) │ │ │ └── shop │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ ├── (learn) │ │ │ └── learn │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ ├── (quests) │ │ │ └── quests │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ ├── (courses) │ │ │ ├── courses │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ └── _components │ │ │ │ ├── card.tsx │ │ │ │ └── list.tsx │ │ ├── (profile) │ │ │ ├── profile │ │ │ │ ├── loading.tsx │ │ │ │ └── [profileId] │ │ │ │ │ ├── loading.tsx │ │ │ │ │ └── not-found.tsx │ │ │ └── _components │ │ │ │ ├── back-redirect.tsx │ │ │ │ ├── private-image-banner.tsx │ │ │ │ ├── solved-challenges.tsx │ │ │ │ ├── active-course-card.tsx │ │ │ │ ├── list.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── profile-hide-component.tsx │ │ │ │ └── bio-form.tsx │ │ ├── (leaderboard) │ │ │ └── leaderboard │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── (challenges) │ │ ├── challenges │ │ │ └── [challengeId] │ │ │ │ ├── loading.tsx │ │ │ │ ├── error.tsx │ │ │ │ └── page.tsx │ │ └── _components │ │ │ ├── bubble-tag.tsx │ │ │ ├── challenge.tsx │ │ │ ├── result-card.tsx │ │ │ ├── finished-screen.tsx │ │ │ ├── footer.tsx │ │ │ └── challenge-option.tsx │ ├── font.ts │ ├── (landing) │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── _components │ │ │ ├── footer.tsx │ │ │ ├── contributors-section.tsx │ │ │ ├── release-section.tsx │ │ │ ├── header.tsx │ │ │ └── hero-section.tsx │ ├── (auth) │ │ ├── sign-in │ │ │ └── [[...sign-in]] │ │ │ │ └── page.tsx │ │ ├── sign-up │ │ │ └── [[...sign-up]] │ │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── not-found.tsx │ ├── layout.tsx │ ├── api │ │ ├── users │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── clerk │ │ │ └── route.ts │ ├── globals.css │ ├── (privacy) │ │ └── privacy │ │ │ └── page.tsx │ └── (terms) │ │ └── terms │ │ └── page.tsx ├── shared │ ├── constant.ts │ ├── contributors-list.ts │ └── release-list.ts ├── queries │ ├── quests-queries.ts │ ├── user-queries.ts │ ├── user-profile-view-queries.ts │ ├── quests-progress-queries.ts │ ├── courses-queries.ts │ ├── user-progress-queries.ts │ └── challenges-queries.ts ├── lib │ └── utils.ts ├── config │ └── site.ts ├── db │ └── index.ts ├── components │ ├── feed-wrapper.tsx │ ├── theme-provider.tsx │ ├── sticky-wrapper.tsx │ ├── code-block │ │ └── code-block.tsx │ ├── search-header.tsx │ ├── header.tsx │ ├── provider │ │ └── modal-provider.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── progress.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── badge.tsx │ │ ├── tooltip.tsx │ │ ├── avatar.tsx │ │ ├── quest-item.tsx │ │ ├── scroll-area.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── table.tsx │ ├── action-tooltip.tsx │ ├── user-button-container.tsx │ ├── wrapper-leaderboard.tsx │ ├── ads-card.tsx │ ├── main-layout-header.tsx │ ├── mode-toggle.tsx │ ├── comment-sheet │ │ ├── index.tsx │ │ ├── comment-form.tsx │ │ └── comment-scroll-area.tsx │ ├── user-progress.tsx │ ├── modal │ │ ├── exit-modal.tsx │ │ ├── error-alert-modal.tsx │ │ ├── profile-view-avatar-modal.tsx │ │ ├── no-enough-hearts-modal.tsx │ │ └── search-users-modal.tsx │ ├── challenges-data-table │ │ ├── columns.tsx │ │ └── index.tsx │ ├── user-item.tsx │ ├── leaderboard-data-table │ │ ├── columns.tsx │ │ └── index.tsx │ ├── profile-view-avatars.tsx │ ├── quests.tsx │ ├── side-bar.tsx │ ├── mobile-side-bar.tsx │ └── main-layout-responsive-header.tsx ├── store │ ├── use-exit-modal-store.ts │ ├── use-search-users-store.ts │ ├── use-footer-card-store.ts │ ├── use-no-enough-hearts-modal-store.ts │ └── use-user-profile-view-avatar-modal-store.ts ├── middleware.ts ├── firebase │ ├── index.ts │ └── actions │ │ └── comments-action.ts ├── actions │ ├── user-profile-view-actions.ts │ ├── user-action.ts │ ├── quest-progress-action.ts │ └── user-progress-action.ts └── types.ts ├── next.config.mjs ├── postcss.config.mjs ├── prisma ├── reset.ts ├── add.ts └── schema.prisma ├── components.json ├── .gitignore ├── tsconfig.json ├── .env-example ├── LICENSE ├── package.json ├── tailwind.config.ts └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/cry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/cry.png -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/img1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/img1.png -------------------------------------------------------------------------------- /public/img2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/img2.png -------------------------------------------------------------------------------- /public/img3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/img3.png -------------------------------------------------------------------------------- /public/img4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/img4.png -------------------------------------------------------------------------------- /public/love.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/love.png -------------------------------------------------------------------------------- /public/robot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/robot.png -------------------------------------------------------------------------------- /public/correct.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/correct.wav -------------------------------------------------------------------------------- /public/finish.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/finish.mp3 -------------------------------------------------------------------------------- /public/question.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/question.png -------------------------------------------------------------------------------- /public/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/search.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/depression.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/depression.png -------------------------------------------------------------------------------- /public/incorrect.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/incorrect.wav -------------------------------------------------------------------------------- /public/robot-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kei-K23/mentor/HEAD/public/robot-error.png -------------------------------------------------------------------------------- /src/shared/constant.ts: -------------------------------------------------------------------------------- 1 | export const POINT_TO_FILL = 10; 2 | export const POINT_TO_FULL_FILL = 50; -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /src/queries/quests-queries.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db" 2 | 3 | export const getAllQuests = async () => { 4 | return await db.quest.findMany(); 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/shared/contributors-list.ts: -------------------------------------------------------------------------------- 1 | export const contributors = [ 2 | { 3 | name: "Kei-K", 4 | githubLink: "https://github.com/Kei-K23", 5 | } 6 | ] -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type 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/queries/user-queries.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db" 2 | 3 | export const getUserByExternalUserId = async (externalUserId: string) => { 4 | return db.user.findUnique({ 5 | where: { 6 | externalUserId 7 | } 8 | }) 9 | } -------------------------------------------------------------------------------- /src/config/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | title: "Mentor", 3 | description: "Mentor is an open-source web application for learning, practicing and mastering programming languages and craft interview questions. The goal of this project to improve the developer community.", 4 | } -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined; 5 | } 6 | 7 | export const db = globalThis.prisma || new PrismaClient(); 8 | 9 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db; 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/feed-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type FeedWrapperProps = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | const FeedWrapper = ({ children }: FeedWrapperProps) => { 8 | return
{children}
; 9 | }; 10 | 11 | export default FeedWrapper; 12 | -------------------------------------------------------------------------------- /src/app/(main)/(shop)/shop/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(learn)/learn/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(quests)/quests/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(courses)/courses/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/profile/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(leaderboard)/leaderboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(challenges)/challenges/[challengeId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/profile/[profileId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const Loading = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default Loading; 13 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /prisma/reset.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | const tableNames = ['Challenge', 'UserProgress']; 4 | 5 | const prisma = new PrismaClient(); 6 | async function main() { 7 | for (const tableName of tableNames) await prisma.$queryRawUnsafe(`Truncate "${tableName}" restart identity cascade;`); 8 | } 9 | 10 | main().finally(async () => { 11 | await prisma.$disconnect(); 12 | }); -------------------------------------------------------------------------------- /src/store/use-exit-modal-store.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { create } from "zustand" 4 | 5 | type UseExitModalStoreProps = { 6 | isOpen: boolean, 7 | open: () => void, 8 | close: () => void 9 | } 10 | 11 | export const useExitModalStore = create(set => ({ 12 | isOpen: false, 13 | open: () => set({ isOpen: true }), 14 | close: () => set({ isOpen: false }), 15 | })); -------------------------------------------------------------------------------- /src/store/use-search-users-store.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { create } from "zustand" 4 | 5 | type UseSearchUsersStoreProps = { 6 | isOpen: boolean, 7 | open: () => void, 8 | close: () => void 9 | } 10 | 11 | export const useSearchUsersStore = create(set => ({ 12 | isOpen: false, 13 | open: () => set({ isOpen: true }), 14 | close: () => set({ isOpen: false }), 15 | })); -------------------------------------------------------------------------------- /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.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "gray", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /src/app/font.ts: -------------------------------------------------------------------------------- 1 | import { Open_Sans, Roboto_Slab } from 'next/font/google' 2 | 3 | export const openSans = Open_Sans({ 4 | subsets: ['latin'], 5 | weight: ['300', '400', '500', '600', '700', "800"], 6 | display: 'auto' 7 | }) 8 | 9 | export const robotoSlab = Roboto_Slab({ 10 | subsets: ['latin'], 11 | weight: ["100", "200", '300', '400', '500', '600', '700', "800", "900"], 12 | display: 'auto' 13 | }) 14 | 15 | -------------------------------------------------------------------------------- /src/queries/user-profile-view-queries.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db" 2 | 3 | export const getUserProfileViewByOwnerId = async (ownerId: string) => { 4 | return await db.userProfileView.findMany({ 5 | where: { 6 | ownerId 7 | }, 8 | include: { 9 | viewer: { 10 | include: { 11 | userProgress: true 12 | } 13 | } 14 | } 15 | }); 16 | } -------------------------------------------------------------------------------- /src/store/use-footer-card-store.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { create } from "zustand" 4 | 5 | type UseFooterCardStoreProps = { 6 | isOpen: boolean, 7 | open: () => void, 8 | close: () => void 9 | } 10 | 11 | // TODO: need to use localStorage 12 | export const useFooterCardStore = create(set => ({ 13 | isOpen: true, 14 | open: () => set({ isOpen: true }), 15 | close: () => set({ isOpen: false }), 16 | })); -------------------------------------------------------------------------------- /src/store/use-no-enough-hearts-modal-store.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { create } from "zustand" 4 | 5 | type UseNoEnoughHeartsModalStoreProps = { 6 | isOpen: boolean, 7 | open: () => void, 8 | close: () => void 9 | } 10 | 11 | export const useNoEnoughHeartsModalStore = create(set => ({ 12 | isOpen: false, 13 | open: () => set({ isOpen: true }), 14 | close: () => set({ isOpen: false }), 15 | })); -------------------------------------------------------------------------------- /src/app/(landing)/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import HeroSection from "./_components/hero-section"; 3 | import ContributorsSection from "./_components/contributors-section"; 4 | import ReleaseSection from "./_components/release-section"; 5 | 6 | const LandingPage = () => { 7 | return ( 8 | <> 9 | 10 | 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default LandingPage; 17 | -------------------------------------------------------------------------------- /src/components/sticky-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type StickyWrapperProps = { 4 | children: React.ReactNode; 5 | }; 6 | 7 | const StickyWrapper = ({ children }: StickyWrapperProps) => { 8 | return ( 9 |
10 |
11 | {children} 12 |
13 |
14 | ); 15 | }; 16 | 17 | export default StickyWrapper; 18 | -------------------------------------------------------------------------------- /src/app/(landing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "./_components/header"; 3 | import Footer from "./_components/footer"; 4 | 5 | const LandingLayout = ({ children }: { children: React.ReactNode }) => { 6 | return ( 7 |
8 |
9 |
10 | {children} 11 |
12 |
13 |
14 | ); 15 | }; 16 | 17 | export default LandingLayout; 18 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; 2 | 3 | const isProtectedRoute = createRouteMatcher([ 4 | '/learn(.*)', 5 | '/courses(.*)', 6 | '/challenges(.*)', 7 | '/shop(.*)', 8 | '/leaderboard(.*)', 9 | '/quests(.*)', 10 | '/api/users(.*)', 11 | ]); 12 | 13 | export default clerkMiddleware((auth, req) => { 14 | if (isProtectedRoute(req)) auth().protect(); 15 | }); 16 | 17 | export const config = { 18 | matcher: ["/((?!.+.[w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 19 | }; -------------------------------------------------------------------------------- /.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 | .env 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignIn } from "@clerk/nextjs"; 4 | import { dark } from "@clerk/themes"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export default function Page() { 8 | const { resolvedTheme } = useTheme(); 9 | 10 | return ( 11 | <> 12 | {resolvedTheme === "dark" ? ( 13 | 19 | ) : ( 20 | 21 | )} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignUp } from "@clerk/nextjs"; 4 | import { dark } from "@clerk/themes"; 5 | import { useTheme } from "next-themes"; 6 | 7 | export default function Page() { 8 | const { resolvedTheme } = useTheme(); 9 | 10 | return ( 11 | <> 12 | {resolvedTheme === "dark" ? ( 13 | 19 | ) : ( 20 | 21 | )} 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/code-block/code-block.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 3 | import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; 4 | 5 | type CodeBlockProps = { 6 | code: string; 7 | language?: string; 8 | }; 9 | 10 | const CodeBlock = ({ code, language }: CodeBlockProps) => { 11 | return ( 12 | 18 | {} 19 | {code} 20 | 21 | ); 22 | }; 23 | 24 | export default CodeBlock; 25 | -------------------------------------------------------------------------------- /src/app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 4 | return ( 5 |
6 |
7 |

Mentor

8 |

9 | A place for programmers to exploring and practice programming language 10 | and interview questions 11 |

12 |
13 | {children} 14 |
15 | ); 16 | }; 17 | 18 | export default AuthLayout; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/search-header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Search } from "lucide-react"; 4 | import React from "react"; 5 | import { Button } from "./ui/button"; 6 | import { useSearchUsersStore } from "@/store/use-search-users-store"; 7 | import ActionTooltip from "./action-tooltip"; 8 | 9 | const SearchHeader = () => { 10 | const { open } = useSearchUsersStore(); 11 | return ( 12 | <> 13 | 14 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default SearchHeader; 23 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/profile/[profileId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { buttonVariants } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import Link from "next/link"; 4 | 5 | export default function NotFound() { 6 | return ( 7 |
8 |

Not Found

9 |

User doest not exist.

10 | 19 | Back to profile 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { Button } from "./ui/button"; 4 | import { ArrowLeft } from "lucide-react"; 5 | 6 | type HeaderProps = { 7 | title: string; 8 | }; 9 | 10 | const Header = ({ title }: HeaderProps) => { 11 | return ( 12 |
13 | 14 | 17 | 18 |

{title}

19 |
20 |
21 | ); 22 | }; 23 | 24 | export default Header; 25 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/back-redirect.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import ActionTooltip from "@/components/action-tooltip"; 3 | import { Button } from "@/components/ui/button"; 4 | import { ArrowLeftSquareIcon } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import React from "react"; 7 | 8 | const BackRedirect = () => { 9 | const router = useRouter(); 10 | return ( 11 | 12 | 19 | 20 | ); 21 | }; 22 | 23 | export default BackRedirect; 24 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/private-image-banner.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React from "react"; 3 | 4 | export default function PrivateImageBanner() { 5 | return ( 6 |
7 | image 14 |

15 | This user is preferred to hide the personal information! 16 |

17 |

18 | You can view this account when owner public their profile. 19 |

20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/provider/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import ExitModal from "../modal/exit-modal"; 5 | import NoEnoughHeartsModal from "../modal/no-enough-hearts-modal"; 6 | import SearchUserModal from "../modal/search-users-modal"; 7 | import ProfileViewAvatarModal from "../modal/profile-view-avatar-modal"; 8 | 9 | const ModalProvider = () => { 10 | const [isClient, setIsClient] = useState(false); 11 | 12 | useEffect(() => setIsClient(true), []); 13 | 14 | if (!isClient) { 15 | return null; 16 | } 17 | return ( 18 | <> 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | 27 | export default ModalProvider; 28 | -------------------------------------------------------------------------------- /src/store/use-user-profile-view-avatar-modal-store.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { UserProfileViewWithViewers } from "@/types"; 4 | import { create } from "zustand" 5 | 6 | type UseUserProfileViewAvatarModalStoreProps = { 7 | isOpen: boolean, 8 | userProfileViews: UserProfileViewWithViewers[], 9 | open: () => void, 10 | close: () => void, 11 | setUserProfileViews: (userProfileViews: UserProfileViewWithViewers[]) => void, 12 | } 13 | 14 | export const useUserProfileViewAvatarModalStore = create(set => ({ 15 | isOpen: false, 16 | userProfileViews: [], 17 | setUserProfileViews: (userProfileViews: UserProfileViewWithViewers[]) => set({ userProfileViews: userProfileViews }), 18 | open: () => set({ isOpen: true }), 19 | close: () => set({ isOpen: false }), 20 | })); -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/action-tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip"; 8 | 9 | type ActionTooltipProps = { 10 | children: React.ReactNode; 11 | text: string; 12 | side?: "top" | "right" | "bottom" | "left"; 13 | align?: "center" | "end" | "start"; 14 | }; 15 | 16 | const ActionTooltip = ({ 17 | children, 18 | text, 19 | side = "top", 20 | align = "center", 21 | }: ActionTooltipProps) => { 22 | return ( 23 | 24 | 25 | {children} 26 | 27 |

{text}

28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default ActionTooltip; 35 | -------------------------------------------------------------------------------- /src/components/user-button-container.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { UserButton } from "@clerk/nextjs"; 3 | import { useTheme } from "next-themes"; 4 | import React from "react"; 5 | import { dark } from "@clerk/themes"; 6 | 7 | const UserButtonContainer = () => { 8 | const { resolvedTheme } = useTheme(); 9 | return ( 10 | <> 11 | {resolvedTheme === "dark" ? ( 12 | 20 | ) : ( 21 | 26 | )} 27 | 28 | ); 29 | }; 30 | 31 | export default UserButtonContainer; 32 | -------------------------------------------------------------------------------- /src/app/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { buttonVariants } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | import Link from "next/link"; 6 | import { usePathname } from "next/navigation"; 7 | 8 | export default function NotFound() { 9 | const pathname = usePathname(); 10 | return ( 11 |
12 |

Page Not Found

13 |

14 | {pathname} is not a valid route. 15 |

16 | 25 | Back to Home 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /.env-example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 2 | CLERK_SECRET_KEY= 3 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 4 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 5 | NEXT_PUBLIC_CLERK_SIGN_UP_FORCE_REDIRECT_URL=/learn 6 | NEXT_PUBLIC_CLERK_SIGN_IN_FORCE_REDIRECT_URL=/learn 7 | NEXT_PUBLIC_CLERK_WEBHOOK_SECRET_KEY= 8 | DATABASE_URL= 9 | NEXT_PUBLIC_FIREBASE_API_KEY= 10 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 11 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 12 | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET= 13 | NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID= 14 | NEXT_PUBLIC_FIREBASE_APP_ID= 15 | NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID= -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /src/components/wrapper-leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import { UserProgressWithUser } from "@/types"; 2 | import React from "react"; 3 | import UserItem from "./user-item"; 4 | 5 | type WrapperLeaderBoardProps = { 6 | usersForLeaderBoard: UserProgressWithUser[]; 7 | }; 8 | 9 | const WrapperLeaderBoard = ({ 10 | usersForLeaderBoard, 11 | }: WrapperLeaderBoardProps) => { 12 | return ( 13 |
14 |

Leader board

15 | {usersForLeaderBoard.length ? ( 16 | usersForLeaderBoard?.map((userProgress, i) => ( 17 | 22 | )) 23 | ) : ( 24 |

No leaderboard data.

25 | )} 26 |
27 | ); 28 | }; 29 | 30 | export default WrapperLeaderBoard; 31 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ProgressPrimitive from "@radix-ui/react-progress"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | const Progress = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, value, ...props }, ref) => ( 12 | 20 | 24 | 25 | )); 26 | Progress.displayName = ProgressPrimitive.Root.displayName; 27 | 28 | export { Progress }; 29 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /public/js.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { buttonVariants } from "@/components/ui/button"; 2 | import { cn } from "@/lib/utils"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | const Footer = () => { 7 | return ( 8 |
9 | 18 | Terms and Conditions 19 | 20 | 29 | Privacy and Policy 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default Footer; 36 | -------------------------------------------------------------------------------- /src/app/(challenges)/_components/bubble-tag.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React from "react"; 3 | 4 | type BubbleTagProps = { 5 | question: string; 6 | }; 7 | 8 | const BubbleTag = ({ question }: BubbleTagProps) => { 9 | return ( 10 |
11 | question image 18 | question image 25 |
26 | {question} 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default BubbleTag; 34 | -------------------------------------------------------------------------------- /src/firebase/index.ts: -------------------------------------------------------------------------------- 1 | // Import the functions you need from the SDKs you need 2 | import { initializeApp } from "firebase/app"; 3 | import { getFirestore } from "firebase/firestore"; 4 | // TODO: Add SDKs for Firebase products that you want to use 5 | // https://firebase.google.com/docs/web/setupavailable-libraries 6 | 7 | // Your web app's Firebase configuration 8 | // For Firebase JS SDK v7.20.0 and later, measurementId is optional 9 | const firebaseConfig = { 10 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 11 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 12 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 13 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 14 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 15 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID, 16 | measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID, 17 | }; 18 | 19 | 20 | const app = initializeApp(firebaseConfig); 21 | 22 | export const db = getFirestore(app); 23 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/solved-challenges.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import React from "react"; 3 | 4 | type SolvedChallengesProps = { 5 | easy: number; 6 | medium: number; 7 | hard: number; 8 | }; 9 | 10 | const SolvedChallenges = ({ easy, medium, hard }: SolvedChallengesProps) => { 11 | return ( 12 | <> 13 |

Solved challenges

14 |
15 | 19 | 23 | 27 |
28 | 29 | ); 30 | }; 31 | 32 | export default SolvedChallenges; 33 | -------------------------------------------------------------------------------- /src/queries/quests-progress-queries.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | import { getUserByExternalUserId } from "./user-queries"; 3 | import { db } from "@/db"; 4 | 5 | export const getQuestsProgress = async () => { 6 | const { userId } = auth(); 7 | 8 | if (!userId) return null; 9 | 10 | const currentUser = await getUserByExternalUserId(userId); 11 | 12 | if (!currentUser) return null; 13 | 14 | return await db.questProgress.findMany({ 15 | where: { 16 | completed: true, 17 | userId: currentUser.id 18 | } 19 | }); 20 | } 21 | 22 | export const getQuestsProgressById = async (questId: number) => { 23 | const { userId } = auth(); 24 | 25 | if (!userId) return null; 26 | 27 | const currentUser = await getUserByExternalUserId(userId); 28 | 29 | if (!currentUser) return null; 30 | 31 | return await db.questProgress.findFirst({ 32 | where: { 33 | questId: questId, 34 | completed: true, 35 | userId: currentUser.id 36 | } 37 | }); 38 | } -------------------------------------------------------------------------------- /src/actions/user-profile-view-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { getUserProfileViewByOwnerId } from "@/queries/user-profile-view-queries"; 5 | import { revalidatePath } from "next/cache"; 6 | 7 | export const createUserProfileView = async (viewerId: string, ownerId: string) => { 8 | try { 9 | if (!viewerId || !ownerId) return; 10 | 11 | if (viewerId === ownerId) return; 12 | 13 | const profileViewers = await getUserProfileViewByOwnerId(ownerId); 14 | 15 | const alreadyViewed = profileViewers.find(pv => pv.viewerId === viewerId); 16 | if (alreadyViewed) { 17 | return { 18 | info: "viewed" 19 | }; 20 | } 21 | await db.userProfileView.create({ 22 | data: { 23 | viewerId, 24 | ownerId 25 | } 26 | }); 27 | 28 | revalidatePath('/profile'); 29 | revalidatePath(`/profile/${ownerId}`); 30 | } catch (e: any) { 31 | throw new Error("Something went wrong when creating user profile view"); 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/ads-card.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card, CardDescription, CardHeader, CardTitle } from "./ui/card"; 3 | import ActionTooltip from "./action-tooltip"; 4 | import { Button } from "./ui/button"; 5 | import { X } from "lucide-react"; 6 | 7 | const AdsCard = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 | Mentor Beta version 0.0.1 14 | 15 | 18 | 19 | 20 | 21 | This is the open-source project for awesome dev community. New 22 | features and more stable version will be released soon. 23 | 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default AdsCard; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Kei-K 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 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Challenge, ChallengeOption, ChallengeProgress, Course, User, UserProfileView, UserProgress } from "@prisma/client"; 2 | 3 | export type ChallengeWithChallengeProgress = Challenge & { 4 | challengeProgress: ChallengeProgress[] | null; 5 | } 6 | 7 | export type ChallengeWithChallengeProgressAndOptions = Challenge & { 8 | challengeProgress: ChallengeProgress[] | null; 9 | challengeOptions: ChallengeOption[] | null; 10 | } 11 | 12 | export type CourseWithChallengeProgressAndChallenges = Course & { 13 | challengeProgress: ChallengeProgress[]; 14 | challenges: Challenge[] 15 | } 16 | 17 | export type UserProgressWithUser = UserProgress & { 18 | user: User 19 | } 20 | 21 | export type UserProgressWithCourse = UserProgress & { 22 | course: Course 23 | } 24 | 25 | export type FirebaseCommentDocType = { 26 | id?: string; 27 | username: string; 28 | comment: string; 29 | userId: string; 30 | challengeId: number; 31 | userImageUrl: string; 32 | createdAt: Date; 33 | updatedAt: Date; 34 | } 35 | 36 | export type UserProfileViewWithViewers = UserProfileView & { 37 | viewer: User 38 | } 39 | -------------------------------------------------------------------------------- /src/app/(main)/(courses)/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCourses } from "@/queries/courses-queries"; 2 | import { getUserProgress } from "@/queries/user-progress-queries"; 3 | import React from "react"; 4 | import List from "../_components/list"; 5 | import { getUserByExternalUserId } from "@/queries/user-queries"; 6 | import { auth } from "@clerk/nextjs/server"; 7 | import { Metadata } from "next"; 8 | 9 | export const metadata: Metadata = { 10 | title: "Courses", 11 | }; 12 | 13 | const CoursesPage = async () => { 14 | const { userId } = auth(); 15 | const coursesData = getCourses(); 16 | const userProgressData = getUserProgress(); 17 | const userData = getUserByExternalUserId(userId!); 18 | 19 | const [courses, userProgress, user] = await Promise.all([ 20 | coursesData, 21 | userProgressData, 22 | userData, 23 | ]); 24 | 25 | return ( 26 |
27 |

Choose your prefer course 🚀

28 | 33 |
34 | ); 35 | }; 36 | 37 | export default CoursesPage; 38 | -------------------------------------------------------------------------------- /public/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 13 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/active-course-card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import React from "react"; 5 | 6 | type ActiveCourseCardProps = { 7 | title: string; 8 | description?: string | null; 9 | imageSrc: string | null; 10 | active: boolean; 11 | }; 12 | 13 | const ActiveCourseCard = ({ 14 | imageSrc, 15 | title, 16 | active, 17 | description, 18 | }: ActiveCourseCardProps) => { 19 | // TODO: check routing and create user progress need for this 20 | return ( 21 |
27 | {title} 28 | 29 |

30 | {title} 31 |

32 |

{description}

33 |
34 | ); 35 | }; 36 | 37 | export default ActiveCourseCard; 38 | -------------------------------------------------------------------------------- /src/components/main-layout-header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import SearchHeader from "./search-header"; 3 | import UserProgress from "./user-progress"; 4 | import Link from "next/link"; 5 | import { cn } from "@/lib/utils"; 6 | import { buttonVariants } from "./ui/button"; 7 | import { UserProgressWithCourse } from "@/types"; 8 | 9 | type MainLayoutHeaderProps = { 10 | userProgress: UserProgressWithCourse; 11 | }; 12 | 13 | const MainLayoutHeader = ({ userProgress }: MainLayoutHeaderProps) => { 14 | return ( 15 |
16 | 17 | {userProgress ? ( 18 | 23 | ) : ( 24 | 33 | Choose your prefer course to learn 34 | 35 | )} 36 |
37 | ); 38 | }; 39 | 40 | export default MainLayoutHeader; 41 | -------------------------------------------------------------------------------- /src/app/(challenges)/challenges/[challengeId]/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; // Error components must be Client Components 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | export default function Error({ 8 | error, 9 | reset, 10 | }: { 11 | error: Error & { digest?: string }; 12 | reset: () => void; 13 | }) { 14 | return ( 15 |
16 |

17 | Oh! Something went wrong 18 |

19 | error img 20 |

21 | This could be third-party error because honestly Mentor only use free 22 | services or due to internal code issues. 23 |

24 | 31 | 32 | Back to Home 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/(challenges)/_components/challenge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import React from "react"; 3 | import ChallengeOptionComponent from "./challenge-option"; 4 | import { ChallengeOption, ChallengeType } from "@prisma/client"; 5 | 6 | type ChallengeProps = { 7 | options: ChallengeOption[]; 8 | onSelect: (id: number) => void; 9 | status: "none" | "correct" | "incorrect"; 10 | selectedOption?: number; 11 | disabled?: boolean; 12 | type: ChallengeType; 13 | }; 14 | 15 | const Challenge = ({ 16 | options, 17 | onSelect, 18 | status, 19 | selectedOption, 20 | disabled, 21 | type, 22 | }: ChallengeProps) => { 23 | return ( 24 |
27 | {options.map((option, i) => ( 28 | onSelect(option.id)} 35 | status={status} 36 | disabled={disabled!} 37 | type={type} 38 | /> 39 | ))} 40 |
41 | ); 42 | }; 43 | 44 | export default Challenge; 45 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | import { openSans } from "./font"; 5 | import { ThemeProvider } from "@/components/theme-provider"; 6 | import { Toaster } from "sonner"; 7 | import ModalProvider from "@/components/provider/modal-provider"; 8 | import { siteConfig } from "@/config/site"; 9 | 10 | export const metadata: Metadata = { 11 | title: { 12 | default: siteConfig.title, 13 | template: `%s | ${siteConfig.title}`, 14 | }, 15 | description: siteConfig.description, 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: Readonly<{ 21 | children: React.ReactNode; 22 | }>) { 23 | return ( 24 | 25 | 26 | 33 | 34 | {children} 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/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 | "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-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 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/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /src/queries/courses-queries.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db" 2 | import { auth } from "@clerk/nextjs/server"; 3 | import { getUserByExternalUserId } from "./user-queries"; 4 | 5 | export const getCourses = async () => { 6 | return db.course.findMany({ 7 | include: { 8 | challengeProgress: true, 9 | challenges: true, 10 | } 11 | }) 12 | } 13 | 14 | export const getFinishedCourses = async () => { 15 | 16 | const { userId } = auth(); 17 | 18 | if (!userId) return null; 19 | 20 | const user = await getUserByExternalUserId(userId); 21 | 22 | if (!user) return null; 23 | 24 | const courses = await db.course.findMany({ 25 | include: { 26 | challengeProgress: true, 27 | challenges: true, 28 | } 29 | }); 30 | 31 | // const challengeProgressFromCourse = course.challengeProgress.filter( 32 | // (cp) => cp.completed && cp.userId === user.id 33 | // ); 34 | // const isCompleted = 35 | // challengeProgressFromCourse.length === course.challenges.length; 36 | 37 | } 38 | 39 | export const getCoursesById = async (id: number) => { 40 | return db.course.findUnique({ 41 | where: { 42 | id 43 | }, 44 | include: { 45 | challenges: true, 46 | } 47 | }) 48 | } -------------------------------------------------------------------------------- /src/shared/release-list.ts: -------------------------------------------------------------------------------- 1 | export const releaseList = [ 2 | { 3 | version: "v0.1.1-beta", 4 | description: "Add profile viewers feature to know who see your profile. Add Private/Public profile toggle feature to hide or show publicly your profile informations", 5 | features: [ 6 | "Profile viewers", 7 | "Private/public profile", 8 | ], 9 | order: 2, 10 | date: "2024-06-20" 11 | }, 12 | { 13 | version: "v0.0.1-beta", 14 | description: "This is first and beta release for Mentor open-source LMS web applications. This release includes basic features and functionality for Mentor.", 15 | features: [ 16 | "Dark mode support", 17 | "Sound effects", 18 | "Hearts system", 19 | "Points / XP system", 20 | "Exit confirmation popup", 21 | "Practice old lessons to regain hearts", 22 | "Leaderboard", 23 | "Quests milestones and gain rewards", 24 | "Shop system to exchange points with hearts", 25 | "Landing page", 26 | "Real - time comments section with Firebase", 27 | "Informative user profile", 28 | "Mobile responsiveness" 29 | ], 30 | order: 1, 31 | date: "2024-05-06" 32 | } 33 | ]; -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/list.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import React from "react"; 3 | import { CourseWithChallengeProgressAndChallenges } from "@/types"; 4 | import Card from "./card"; 5 | 6 | type ListProps = { 7 | courses: CourseWithChallengeProgressAndChallenges[]; 8 | user: User; 9 | }; 10 | 11 | const List = ({ courses, user }: ListProps) => { 12 | const completedCourse = courses.filter((course) => { 13 | const challengeProgressFromCourse = course.challengeProgress.filter( 14 | (cp) => cp.completed && cp.userId === user.id 15 | ); 16 | const isCompleted = 17 | challengeProgressFromCourse.length === course.challenges.length; 18 | if (isCompleted) { 19 | return course; 20 | } 21 | }); 22 | 23 | return ( 24 |
25 | {!completedCourse.length ? ( 26 |

No completed course yet!

27 | ) : ( 28 | completedCourse.map((course) => ( 29 | 36 | )) 37 | )} 38 |
39 | ); 40 | }; 41 | 42 | export default List; 43 | -------------------------------------------------------------------------------- /src/app/api/users/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { NextRequest } from "next/server"; 3 | 4 | export async function GET(req: NextRequest) { 5 | const searchParams = req.nextUrl.searchParams 6 | const username = searchParams.get('name') ?? ""; 7 | try { 8 | 9 | if (username === "undefined") { 10 | 11 | const users = await db.userProgress.findMany({ 12 | orderBy: { 13 | points: "desc" 14 | }, 15 | take: 3, 16 | include: { 17 | user: true 18 | } 19 | }); 20 | 21 | return Response.json(users); 22 | 23 | } else { 24 | const users = await db.userProgress.findMany({ 25 | where: { 26 | user: { 27 | username: { 28 | contains: username 29 | } 30 | } 31 | }, 32 | orderBy: { 33 | points: "desc" 34 | }, 35 | include: { 36 | user: true 37 | } 38 | }); 39 | return Response.json(users); 40 | } 41 | } catch (e: any) { 42 | return new Response("Something went wrong", { status: 500 }); 43 | } 44 | } -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/card.tsx: -------------------------------------------------------------------------------- 1 | import ActionTooltip from "@/components/action-tooltip"; 2 | import { cn } from "@/lib/utils"; 3 | import { BookmarkCheckIcon } from "lucide-react"; 4 | import Image from "next/image"; 5 | import React from "react"; 6 | 7 | type CardProps = { 8 | id: number; 9 | title: string; 10 | description?: string | null; 11 | imageSrc: string | null; 12 | }; 13 | 14 | const Card = ({ id, imageSrc, title, description }: CardProps) => { 15 | return ( 16 |
21 |
22 | 23 |
24 | 25 |
26 |
27 |
28 | 29 | {title} 30 | 31 |

32 | {title} 33 |

34 |

{description}

35 |
36 | ); 37 | }; 38 | 39 | export default Card; 40 | -------------------------------------------------------------------------------- /public/php.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | 5 | import { useTheme } from "next-themes"; 6 | 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | import { MoonIcon, SunIcon } from "lucide-react"; 15 | 16 | export function ModeToggle() { 17 | const { setTheme } = useTheme(); 18 | 19 | return ( 20 | 21 | 22 | 27 | 28 | 29 | setTheme("light")}> 30 | Light 31 | 32 | setTheme("dark")}> 33 | Dark 34 | 35 | setTheme("system")}> 36 | System 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/comment-sheet/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Sheet, 3 | SheetContent, 4 | SheetHeader, 5 | SheetTitle, 6 | SheetTrigger, 7 | } from "@/components/ui/sheet"; 8 | 9 | import React from "react"; 10 | import { Button } from "../ui/button"; 11 | import { NotebookPen } from "lucide-react"; 12 | import CommentForm from "./comment-form"; 13 | import CommentScrollArea from "./comment-scroll-area"; 14 | import { useUser } from "@clerk/nextjs"; 15 | 16 | type CommentSheetProps = { 17 | challengeId: number; 18 | }; 19 | 20 | const CommentSheet = ({ challengeId }: CommentSheetProps) => { 21 | const { user } = useUser(); 22 | 23 | // TODO: check user exist or not if need 24 | 25 | return ( 26 | 27 | 28 | 31 | 32 | 33 | 34 | Comment section 35 | 36 | 40 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default CommentSheet; 52 | -------------------------------------------------------------------------------- /public/go.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/profile-hide-component.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { toggleUserProfileView } from "@/actions/user-action"; 4 | import { Label } from "@/components/ui/label"; 5 | 6 | import Switch from "react-switch"; 7 | import React, { useState, useTransition } from "react"; 8 | import { toast } from "sonner"; 9 | 10 | type ProfileHideComponentProps = { 11 | privateProfile: boolean; 12 | }; 13 | 14 | const ProfileHideComponent = ({ 15 | privateProfile, 16 | }: ProfileHideComponentProps) => { 17 | const [pending, startTransition] = useTransition(); 18 | const [isPrivate, setIsPrivate] = useState(privateProfile); 19 | 20 | const handleOnChange = (value: boolean) => { 21 | startTransition(() => { 22 | toggleUserProfileView(value) 23 | .then(() => { 24 | setIsPrivate(value); 25 | }) 26 | .catch((e) => { 27 | console.log(e); 28 | 29 | toast.error("Something went wrong"); 30 | }); 31 | }); 32 | }; 33 | 34 | return ( 35 |
36 | 39 | handleOnChange(checked)} 41 | disabled={pending} 42 | checked={isPrivate} 43 | checkedIcon={false} 44 | uncheckedIcon={false} 45 | /> 46 |
47 | ); 48 | }; 49 | 50 | export default ProfileHideComponent; 51 | -------------------------------------------------------------------------------- /src/app/(challenges)/_components/result-card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Image from "next/image"; 3 | import React from "react"; 4 | 5 | type ResultCardProps = { 6 | variant: "points" | "hearts"; 7 | values: number; 8 | }; 9 | 10 | const ResultCard = ({ values, variant }: ResultCardProps) => { 11 | const imgSrc = variant === "points" ? "/points.svg" : "/heart.svg"; 12 | 13 | return ( 14 |
21 |
28 | {variant === "hearts" ? "Hearts Left" : "Total XP"} 29 |
30 |
37 | <> 38 | {variant} 45 | {values} 46 | 47 |
48 |
49 | ); 50 | }; 51 | 52 | export default ResultCard; 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/java.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/user-progress.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { Button } from "./ui/button"; 4 | import Image from "next/image"; 5 | import { Course } from "@prisma/client"; 6 | import ActionTooltip from "./action-tooltip"; 7 | 8 | type UserProgressProps = { 9 | activeCourse: Course; 10 | hearts: number; 11 | points: number; 12 | }; 13 | 14 | const UserProgress = ({ activeCourse, hearts, points }: UserProgressProps) => { 15 | return ( 16 |
17 | 18 | 19 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 49 | 50 |
51 | ); 52 | }; 53 | 54 | export default UserProgress; 55 | -------------------------------------------------------------------------------- /src/queries/user-progress-queries.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { auth } from "@clerk/nextjs/server" 3 | import { getUserByExternalUserId } from "./user-queries"; 4 | 5 | export const getUserProgress = async () => { 6 | const { userId } = auth(); 7 | 8 | if (!userId) return null; 9 | 10 | const currentUser = await getUserByExternalUserId(userId); 11 | 12 | if (!currentUser) return null; 13 | 14 | return db.userProgress.findUnique({ 15 | where: { 16 | userId: currentUser.id 17 | }, 18 | include: { 19 | course: true 20 | } 21 | }); 22 | } 23 | 24 | export const getUserProgressByExternalId = async (externalUserId: string) => { 25 | const currentUser = await getUserByExternalUserId(externalUserId); 26 | 27 | if (!currentUser) return null; 28 | 29 | return db.userProgress.findUnique({ 30 | where: { 31 | userId: currentUser.id 32 | }, 33 | include: { 34 | course: true 35 | } 36 | }); 37 | } 38 | 39 | export const getUsersForLeaderBoard = async (limit?: number) => { 40 | const { userId } = auth(); 41 | 42 | if (!userId) return null; 43 | 44 | if (limit) { 45 | return db.userProgress.findMany({ 46 | orderBy: { 47 | points: "desc" 48 | }, 49 | take: limit, 50 | include: { 51 | user: true 52 | } 53 | }); 54 | } else { 55 | return db.userProgress.findMany({ 56 | orderBy: { 57 | points: "desc" 58 | }, 59 | include: { 60 | user: true 61 | } 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/contributors-section.tsx: -------------------------------------------------------------------------------- 1 | import { robotoSlab } from "@/app/font"; 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 | import { cn } from "@/lib/utils"; 4 | import { contributors } from "@/shared/contributors-list"; 5 | import Link from "next/link"; 6 | import React from "react"; 7 | 8 | const ContributorsSection = () => { 9 | return ( 10 |
11 |

17 | Our contributors 18 |

19 |

20 | This project grows with the contributions of these contributors. 21 |

22 |
23 | {contributors.map((c) => ( 24 | 30 | 31 | 35 | {c.name} 36 | 37 |

{c.name}

38 | 39 | ))} 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default ContributorsSection; 46 | -------------------------------------------------------------------------------- /src/components/modal/exit-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "@/components/ui/dialog"; 12 | 13 | import { useRouter } from "next/navigation"; 14 | import Image from "next/image"; 15 | import { Button } from "../ui/button"; 16 | import { useExitModalStore } from "@/store/use-exit-modal-store"; 17 | 18 | const ExitModal = () => { 19 | const router = useRouter(); 20 | 21 | const { isOpen, close } = useExitModalStore(); 22 | 23 | return ( 24 | 25 | 26 | 27 |
28 | cyr image 29 |
30 | 31 | Just try with me! 32 | 33 | 34 | You've about to leave the challenge. Are you sure? 35 | 36 |
37 | 38 |
39 | 40 | 49 |
50 |
51 |
52 |
53 | ); 54 | }; 55 | 56 | export default ExitModal; 57 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/release-section.tsx: -------------------------------------------------------------------------------- 1 | import { robotoSlab } from "@/app/font"; 2 | import { cn } from "@/lib/utils"; 3 | import { releaseList } from "@/shared/release-list"; 4 | import React from "react"; 5 | 6 | const ReleaseSection = () => { 7 | return ( 8 |
9 |

15 | Project release 16 |

17 |
18 | {releaseList.map((r) => ( 19 |
20 |

21 | {r.version}{" "} 22 | {r.date} 23 |

24 |

{r.description}

25 | {r.features.length && ( 26 |
27 |

28 | Features: 29 |

30 |
31 | {r.features.map((f) => ( 32 |
36 | {f} 37 |
38 | ))} 39 |
40 |
41 | )} 42 |
43 | ))} 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default ReleaseSection; 50 | -------------------------------------------------------------------------------- /src/app/(landing)/_components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { ModeToggle } from "@/components/mode-toggle"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | ClerkLoaded, 6 | ClerkLoading, 7 | SignedIn, 8 | SignedOut, 9 | SignInButton, 10 | } from "@clerk/nextjs"; 11 | import { Loader, LogInIcon } from "lucide-react"; 12 | import Image from "next/image"; 13 | import Link from "next/link"; 14 | import React from "react"; 15 | import UserButtonContainer from "@/components/user-button-container"; 16 | 17 | const Header = () => { 18 | return ( 19 |
20 | 45 |
46 | ); 47 | }; 48 | 49 | export default Header; 50 | -------------------------------------------------------------------------------- /src/components/ui/quest-item.tsx: -------------------------------------------------------------------------------- 1 | import { Quest } from "@prisma/client"; 2 | import Image from "next/image"; 3 | import React from "react"; 4 | import { Progress } from "./progress"; 5 | import ActionTooltip from "../action-tooltip"; 6 | import { CheckCheckIcon } from "lucide-react"; 7 | import { Button } from "./button"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | type QuestItemProps = { 11 | quest: Quest; 12 | progress: number; 13 | isClaim: boolean; 14 | onClick: (id: number, points: number) => void; 15 | pending: boolean; 16 | }; 17 | 18 | const QuestItem = ({ 19 | quest, 20 | progress, 21 | isClaim, 22 | onClick, 23 | pending, 24 | }: QuestItemProps) => { 25 | return ( 26 |
30 | points 31 |
32 |

33 | {quest.title} 34 |

35 | 36 |
37 |
38 | {isClaim && ( 39 | 40 | 41 | 42 | )} 43 | {!isClaim && progress >= 100 && ( 44 | 51 | )} 52 |
53 |
54 | ); 55 | }; 56 | 57 | export default QuestItem; 58 | -------------------------------------------------------------------------------- /src/app/(main)/(courses)/_components/card.tsx: -------------------------------------------------------------------------------- 1 | import ActionTooltip from "@/components/action-tooltip"; 2 | import { cn } from "@/lib/utils"; 3 | import { BookmarkCheckIcon } from "lucide-react"; 4 | import Image from "next/image"; 5 | import React from "react"; 6 | 7 | type CardProps = { 8 | id: number; 9 | title: string; 10 | description?: string | null; 11 | imageSrc: string | null; 12 | onClick: (id: number) => void; 13 | disabled: boolean; 14 | active: boolean; 15 | isCompleted: boolean; 16 | }; 17 | 18 | const Card = ({ 19 | id, 20 | imageSrc, 21 | title, 22 | onClick, 23 | disabled, 24 | active, 25 | description, 26 | isCompleted, 27 | }: CardProps) => { 28 | return ( 29 |
onClick(id)} 31 | className={cn( 32 | "relative h-full border-2 rounded-xl border-b-4 hover:bg-black/5 dark:hover:bg-slate-900/60 cursor-pointer active:border-b-2 flex flex-col items-center justify-center p-3 pb-6 min-h-[217px] min-w-[200px] select-none", 33 | disabled && "pointer-events-none opacity-50", 34 | active && "border-sky-300" 35 | )} 36 | > 37 |
38 | {isCompleted && ( 39 | 40 |
41 | 42 |
43 |
44 | )} 45 |
46 | 47 | {title} 48 | 49 |

50 | {title} 51 |

52 |

{description}

53 |
54 | ); 55 | }; 56 | 57 | export default Card; 58 | -------------------------------------------------------------------------------- /prisma/add.ts: -------------------------------------------------------------------------------- 1 | import { ChallengeType, Difficulty, PrismaClient } from '@prisma/client' 2 | const prisma = new PrismaClient() 3 | async function main() { 4 | console.log("Start database seeding"); 5 | 6 | const challenge = await prisma.challenge.create({ 7 | data: 8 | { 9 | title: "JS interview question 12", 10 | code: ` 11 | (function(){ 12 | var animal = ['cow','horse']; 13 | animal.push('cat'); 14 | animal.push('dog','rat','goat'); 15 | console.log(animal.length); 16 | })(); 17 | `, 18 | courseId: 41, 19 | difficulty: Difficulty.MEDIUM, 20 | order: 12, 21 | question: "What would be the output of following code ?", 22 | type: ChallengeType.MULTIPLE_CHOICE 23 | }, 24 | } 25 | ); 26 | 27 | await prisma.challengeOption.createMany({ 28 | data: [ 29 | { 30 | challengeId: challenge.id, 31 | text: "11", 32 | correct: false 33 | }, 34 | { 35 | challengeId: challenge.id, 36 | text: "5", 37 | correct: false 38 | }, 39 | { 40 | challengeId: challenge.id, 41 | text: `6`, 42 | correct: true 43 | }, 44 | { 45 | challengeId: challenge.id, 46 | text: "undefined", 47 | correct: false 48 | }, 49 | ] 50 | }) 51 | 52 | 53 | 54 | 55 | 56 | console.log("Database seeding finished"); 57 | } 58 | 59 | main() 60 | .then(async () => { 61 | await prisma.$disconnect() 62 | }) 63 | .catch(async (e) => { 64 | console.error(e) 65 | await prisma.$disconnect() 66 | process.exit(1) 67 | }) 68 | 69 | -------------------------------------------------------------------------------- /src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const ScrollArea = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, children, ...props }, ref) => ( 12 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | )) 24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 25 | 26 | const ScrollBar = React.forwardRef< 27 | React.ElementRef, 28 | React.ComponentPropsWithoutRef 29 | >(({ className, orientation = "vertical", ...props }, ref) => ( 30 | 43 | 44 | 45 | )) 46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 47 | 48 | export { ScrollArea, ScrollBar } 49 | -------------------------------------------------------------------------------- /src/actions/user-action.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { db } from "@/db"; 4 | import { getUserByExternalUserId } from "@/queries/user-queries"; 5 | import { auth } from "@clerk/nextjs/server"; 6 | import { revalidatePath } from "next/cache"; 7 | 8 | export const createUserBio = async (bio: string) => { 9 | try { 10 | 11 | if (bio === "") throw new Error("Bio is empty!") 12 | 13 | const { userId } = auth(); 14 | 15 | if (!userId) throw new Error("Unauthorized!"); 16 | 17 | const user = await getUserByExternalUserId(userId); 18 | 19 | if (!user) throw new Error("User not found!"); 20 | 21 | await db.user.update({ 22 | where: { 23 | id: user.id, 24 | externalUserId: userId 25 | }, 26 | data: { 27 | bio 28 | } 29 | }); 30 | 31 | revalidatePath("/profile"); 32 | } catch (e) { 33 | throw new Error("Something went wrong"); 34 | } 35 | } 36 | 37 | export const toggleUserProfileView = async (isPrivate: boolean) => { 38 | try { 39 | 40 | if (isPrivate === null) throw new Error("Missing something") 41 | 42 | const { userId } = auth(); 43 | 44 | if (!userId) throw new Error("Unauthorized!"); 45 | 46 | const user = await getUserByExternalUserId(userId); 47 | 48 | if (!user) throw new Error("User not found!"); 49 | 50 | await db.user.update({ 51 | where: { 52 | id: user.id, 53 | externalUserId: userId 54 | }, 55 | data: { 56 | privateProfile: isPrivate 57 | } 58 | }); 59 | 60 | revalidatePath("/profile"); 61 | revalidatePath(`/profile/${user.externalUserId}`) 62 | } catch (e) { 63 | throw new Error("Something went wrong"); 64 | } 65 | } -------------------------------------------------------------------------------- /src/actions/quest-progress-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { db } from "@/db"; 4 | import { getQuestsProgressById } from "@/queries/quests-progress-queries"; 5 | import { getUserProgress } from "@/queries/user-progress-queries"; 6 | import { getUserByExternalUserId } from "@/queries/user-queries"; 7 | import { auth } from "@clerk/nextjs/server"; 8 | import { revalidatePath } from "next/cache"; 9 | 10 | export const createQuestProgress = async (questId: number, points: number) => { 11 | 12 | try { 13 | if (!questId) throw new Error("Quest id is required"); 14 | 15 | const { userId } = auth(); 16 | 17 | if (!userId) throw new Error("Unauthorized!"); 18 | 19 | const user = await getUserByExternalUserId(userId); 20 | 21 | if (!user) throw new Error("User not found!"); 22 | 23 | const userProgress = await getUserProgress(); 24 | 25 | if (!userProgress || !userProgress.courseId) throw new Error("User progress not found!"); 26 | 27 | const questProgress = await getQuestsProgressById(questId); 28 | 29 | if (questProgress) throw new Error("Quest is already claim!"); 30 | 31 | await db.questProgress.create({ 32 | data: { 33 | questId, 34 | userId: user.id, 35 | completed: true 36 | } 37 | }); 38 | 39 | await db.userProgress.update({ 40 | where: { 41 | userId: user.id, 42 | }, 43 | data: { 44 | points: userProgress.points + points 45 | } 46 | }); 47 | 48 | revalidatePath("/"); 49 | revalidatePath("/learn"); 50 | revalidatePath("/challenges"); 51 | revalidatePath("/quests"); 52 | revalidatePath("/leaderboard"); 53 | } catch (e) { 54 | throw new Error("Something went wrong"); 55 | } 56 | } 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | :root { 4 | height: 100%; 5 | } 6 | 7 | @tailwind base; 8 | @tailwind components; 9 | @tailwind utilities; 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 224 71.4% 4.1%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 224 71.4% 4.1%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 224 71.4% 4.1%; 21 | 22 | --primary: 220.9 39.3% 11%; 23 | --primary-foreground: 210 20% 98%; 24 | 25 | --secondary: 220 14.3% 95.9%; 26 | --secondary-foreground: 220.9 39.3% 11%; 27 | 28 | --muted: 220 14.3% 95.9%; 29 | --muted-foreground: 220 8.9% 46.1%; 30 | 31 | --accent: 220 14.3% 95.9%; 32 | --accent-foreground: 220.9 39.3% 11%; 33 | 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 210 20% 98%; 36 | 37 | --border: 220 13% 91%; 38 | --input: 220 13% 91%; 39 | --ring: 224 71.4% 4.1%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 224 71.4% 4.1%; 46 | --foreground: 210 20% 98%; 47 | 48 | --card: 224 71.4% 4.1%; 49 | --card-foreground: 210 20% 98%; 50 | 51 | --popover: 224 71.4% 4.1%; 52 | --popover-foreground: 210 20% 98%; 53 | 54 | --primary: 210 20% 98%; 55 | --primary-foreground: 220.9 39.3% 11%; 56 | 57 | --secondary: 215 27.9% 16.9%; 58 | --secondary-foreground: 210 20% 98%; 59 | 60 | --muted: 215 27.9% 16.9%; 61 | --muted-foreground: 217.9 10.6% 64.9%; 62 | 63 | --accent: 215 27.9% 16.9%; 64 | --accent-foreground: 210 20% 98%; 65 | 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 210 20% 98%; 68 | 69 | --border: 215 27.9% 16.9%; 70 | --input: 215 27.9% 16.9%; 71 | --ring: 216 12.2% 83.9%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | 80 | body { 81 | @apply bg-background text-foreground; 82 | } 83 | } -------------------------------------------------------------------------------- /src/app/(main)/(courses)/_components/list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User } from "@prisma/client"; 4 | import { useRouter } from "next/navigation"; 5 | import React, { useTransition } from "react"; 6 | import Card from "./card"; 7 | import { createUserProgress } from "@/actions/user-progress-action"; 8 | import { toast } from "sonner"; 9 | import { CourseWithChallengeProgressAndChallenges } from "@/types"; 10 | 11 | type ListProps = { 12 | courses: CourseWithChallengeProgressAndChallenges[]; 13 | activeCourseId: number; 14 | user: User; 15 | }; 16 | 17 | const List = ({ courses, activeCourseId, user }: ListProps) => { 18 | const router = useRouter(); 19 | 20 | const [pending, startTransition] = useTransition(); 21 | 22 | const onClick = (id: number) => { 23 | if (pending) return; 24 | 25 | if (id === activeCourseId) { 26 | return router.push("/learn"); 27 | } 28 | 29 | startTransition(() => { 30 | createUserProgress(id) 31 | .then(() => router.push("/learn")) 32 | .catch((e) => toast.error(e)); 33 | }); 34 | }; 35 | 36 | return ( 37 |
38 | {courses.map((course) => { 39 | const challengeProgressFromCourse = course.challengeProgress.filter( 40 | (cp) => cp.completed && cp.userId === user?.id 41 | ); 42 | const isCompleted = 43 | challengeProgressFromCourse.length === course.challenges.length; 44 | return ( 45 | 56 | ); 57 | })} 58 |
59 | ); 60 | }; 61 | 62 | export default List; 63 | -------------------------------------------------------------------------------- /src/actions/user-progress-action.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { db } from "@/db"; 4 | import { getCoursesById } from "@/queries/courses-queries"; 5 | import { getUserProgress } from "@/queries/user-progress-queries"; 6 | import { getUserByExternalUserId } from "@/queries/user-queries"; 7 | import { auth } from "@clerk/nextjs/server"; 8 | import { revalidatePath } from "next/cache"; 9 | 10 | // TODO: js course is not work 11 | export const createUserProgress = async (courseId: number) => { 12 | try { 13 | 14 | if (!courseId) throw new Error("Missing course id."); 15 | 16 | const { userId } = auth(); 17 | 18 | if (!userId) throw new Error("Unauthorized!"); 19 | 20 | const user = await getUserByExternalUserId(userId); 21 | 22 | if (!user || user.externalUserId !== userId) throw new Error("User cannot find!"); 23 | 24 | const course = await getCoursesById(courseId); 25 | 26 | if (!course) throw new Error("Course not found."); 27 | 28 | if (!course.challenges.length) throw new Error("Course is empty."); 29 | 30 | const existingUserProgress = await getUserProgress(); 31 | 32 | if (existingUserProgress) { 33 | await db.userProgress.update({ 34 | where: { 35 | userId: user.id 36 | }, 37 | data: { 38 | courseId: course.id 39 | } 40 | }); 41 | 42 | revalidatePath("/courses"); 43 | revalidatePath("/learn"); 44 | revalidatePath("/challenges"); 45 | return; 46 | } 47 | 48 | await db.userProgress.create({ 49 | data: { 50 | userId: user.id, 51 | courseId: course.id, 52 | } 53 | }); 54 | 55 | revalidatePath("/courses"); 56 | revalidatePath("/learn"); 57 | revalidatePath("/challenges"); 58 | return; 59 | } catch (e: any) { 60 | 61 | throw new Error("Something went wrong!"); 62 | } 63 | } -------------------------------------------------------------------------------- /src/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 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-sky-500 text-primary-foreground hover:bg-sky-500/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 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 | -------------------------------------------------------------------------------- /src/app/(main)/(profile)/_components/bio-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createUserBio } from "@/actions/user-action"; 4 | import ActionTooltip from "@/components/action-tooltip"; 5 | import { Input } from "@/components/ui/input"; 6 | import React, { useState, useTransition } from "react"; 7 | import { toast } from "sonner"; 8 | 9 | type BioFormProps = { 10 | initialBio: string; 11 | notEditable?: boolean; 12 | }; 13 | 14 | const BioForm = ({ initialBio, notEditable = false }: BioFormProps) => { 15 | const [isEdit, setIsEdit] = useState(false); 16 | const [pending, startTransition] = useTransition(); 17 | const [bio, setBio] = useState(initialBio ?? ""); 18 | 19 | const createBio = (bio: string) => { 20 | if (bio === "") return; 21 | 22 | if (notEditable) return; 23 | 24 | if (initialBio === bio) { 25 | return setIsEdit(false); 26 | } 27 | 28 | startTransition(() => { 29 | createUserBio(bio) 30 | .then(() => { 31 | toast.success("Updated bio successfully"); 32 | }) 33 | .catch((e) => toast.error(e)); 34 | setIsEdit(false); 35 | }); 36 | }; 37 | 38 | return ( 39 |
40 | {isEdit ? ( 41 | createBio(bio)} 43 | onKeyDown={(e) => { 44 | if (e.key === "Enter") { 45 | createBio(bio); 46 | } 47 | }} 48 | disabled={pending} 49 | placeholder="write your bio" 50 | value={bio} 51 | autoFocus 52 | onChange={(e) => setBio(e.target.value)} 53 | /> 54 | ) : ( 55 | 56 |

{ 59 | if (notEditable) return; 60 | setIsEdit(true); 61 | }} 62 | > 63 | {bio === "" ? "This user is not provide bio." : bio} 64 |

65 |
66 | )} 67 |
68 | ); 69 | }; 70 | 71 | export default BioForm; 72 | -------------------------------------------------------------------------------- /src/components/challenges-data-table/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { ChallengeWithChallengeProgress } from "@/types"; 5 | import { ColumnDef } from "@tanstack/react-table"; 6 | import { CircleCheckBigIcon } from "lucide-react"; 7 | import Link from "next/link"; 8 | import CommentSheet from "../comment-sheet"; 9 | 10 | export const columns: ColumnDef[] = [ 11 | { 12 | accessorKey: "status", 13 | header: "Status", 14 | // TODO : check to make sure status condition is work without user id 15 | cell: ({ row }) => { 16 | return ( 17 |
18 | {row.original.challengeProgress?.some( 19 | (cp) => cp.challengeId === row.original.id && cp.completed 20 | ) ? ( 21 | 22 | ) : ( 23 | "" 24 | )}{" "} 25 |
26 | ); 27 | }, 28 | }, 29 | { 30 | accessorKey: "title", 31 | header: "Title", 32 | cell: ({ row }) => { 33 | return ( 34 | 38 | {row.original.id}. {row.original.title} 39 | 40 | ); 41 | }, 42 | }, 43 | { 44 | accessorKey: "difficulty", 45 | header: "Difficulty", 46 | cell: ({ row }) => { 47 | return ( 48 |
49 |

57 | {row.original.difficulty} 58 |

59 |
60 | ); 61 | }, 62 | }, 63 | { 64 | id: "actions", 65 | cell: ({ row }) => { 66 | const id = row.original.id; 67 | 68 | return ; 69 | }, 70 | }, 71 | ]; 72 | -------------------------------------------------------------------------------- /src/components/user-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Avatar, AvatarImage } from "./ui/avatar"; 3 | import ActionTooltip from "./action-tooltip"; 4 | import Link from "next/link"; 5 | import Image from "next/image"; 6 | import { UserProgressWithUser } from "@/types"; 7 | import { cn } from "@/lib/utils"; 8 | 9 | type UserItemProps = { 10 | userProgress: UserProgressWithUser; 11 | index: number; 12 | onClose?: () => void; 13 | showUserRank?: boolean; 14 | }; 15 | 16 | const UserItem = ({ 17 | userProgress, 18 | index, 19 | onClose, 20 | showUserRank = true, 21 | }: UserItemProps) => { 22 | return ( 23 |
27 | {showUserRank && ( 28 |

37 | {index + 1} 38 |

39 | )} 40 | 41 | 42 | {/* TODO: handle if user img is null */} 43 | 47 | 48 | 49 | 54 | {userProgress.user.username} 55 | 56 | 57 |
58 | points 59 |

{userProgress.points} XP

60 |
61 |
62 | ); 63 | }; 64 | 65 | export default UserItem; 66 | -------------------------------------------------------------------------------- /src/components/modal/error-alert-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from "react"; 3 | 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | } from "@/components/ui/dialog"; 12 | 13 | import { useRouter } from "next/navigation"; 14 | import Image from "next/image"; 15 | import { Button } from "../ui/button"; 16 | 17 | const ErrorAlertModal = () => { 18 | const router = useRouter(); 19 | 20 | const [isClient, setIsClient] = useState(false); 21 | 22 | useEffect(() => { 23 | setIsClient(true); 24 | }, []); 25 | 26 | if (!isClient) { 27 | return null; 28 | } 29 | 30 | return ( 31 | 32 | 33 | 34 |
35 | error image 41 |
42 | 43 | Encounter some issues when loading data 44 | 45 | 46 | Please refresh the page again. If error still exist, then back to 47 | the main page. 48 | 49 |
50 | 51 |
52 | 59 | 67 |
68 |
69 |
70 |
71 | ); 72 | }; 73 | 74 | export default ErrorAlertModal; 75 | -------------------------------------------------------------------------------- /src/components/leaderboard-data-table/columns.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import { UserProgressWithUser } from "@/types"; 5 | import { ColumnDef } from "@tanstack/react-table"; 6 | import Link from "next/link"; 7 | import { Avatar, AvatarImage } from "../ui/avatar"; 8 | import ActionTooltip from "../action-tooltip"; 9 | import Image from "next/image"; 10 | 11 | export const columns: ColumnDef[] = [ 12 | { 13 | accessorKey: "rank", 14 | header: "Rank", 15 | cell: ({ row }) => { 16 | return ( 17 |

26 | {row.index + 1} 27 |

28 | ); 29 | }, 30 | }, 31 | { 32 | accessorKey: "user", 33 | header: "User", 34 | cell: ({ row }) => { 35 | return ( 36 |
37 | 38 | 42 | 43 | 44 | 48 | {row.original.user.username} 49 | 50 | 51 |
52 | ); 53 | }, 54 | }, 55 | { 56 | accessorKey: "points", 57 | header: "Points", 58 | cell: ({ row }) => { 59 | return ( 60 |
61 | points 62 |

{row.original.points} XP

63 |
64 | ); 65 | }, 66 | }, 67 | ]; 68 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/firebase/actions/comments-action.ts: -------------------------------------------------------------------------------- 1 | import { FirebaseCommentDocType } from "@/types"; 2 | import { collection, addDoc, deleteDoc, getDoc, query, where, doc, updateDoc } from "firebase/firestore"; 3 | import { db } from ".."; 4 | 5 | export const createComment = async (comment: FirebaseCommentDocType) => { 6 | try { 7 | const collRef = collection(db, "comments"); 8 | const res = await addDoc(collRef, comment); 9 | 10 | if (!res.id) { 11 | throw new Error("Could not create comment"); 12 | } 13 | } catch (e: any) { 14 | console.log(e); 15 | 16 | throw new Error("Something went wrong") 17 | } 18 | } 19 | 20 | export const deleteComment = async ({ commentId, userId, challengeId }: { commentId: string, userId: string, challengeId: number }) => { 21 | try { 22 | const docRef = doc(db, "comments", commentId); 23 | const commentDoc = await getDoc(docRef); 24 | if (!commentDoc.exists()) { 25 | throw new Error("Could not find comment to delete"); 26 | } else { 27 | if (commentDoc.data().userId === userId && commentDoc.data().challengeId === challengeId) { 28 | await deleteDoc(docRef); 29 | } else { 30 | throw new Error("Unauthorized"); 31 | } 32 | } 33 | } catch (e: any) { 34 | throw new Error("Something went wrong") 35 | } 36 | } 37 | 38 | export const updateComment = async ({ commentId, userId, challengeId, comment }: { commentId: string, userId: string, challengeId: number, comment: string }) => { 39 | try { 40 | const docRef = doc(db, "comments", commentId); 41 | const commentDoc = await getDoc(docRef); 42 | if (!commentDoc.exists()) { 43 | throw new Error("Could not find comment to update"); 44 | } else { 45 | if (commentDoc.data().userId === userId && commentDoc.data().challengeId === challengeId) { 46 | await updateDoc(docRef, { 47 | comment: comment 48 | }); 49 | } else { 50 | throw new Error("Unauthorized"); 51 | } 52 | } 53 | } catch (e: any) { 54 | throw new Error("Something went wrong") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mentor", 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 | "db:push": "npx prisma db push", 11 | "db:seed": "tsx prisma/seed.ts", 12 | "db:add": "tsx prisma/add.ts", 13 | "db:reset": "tsx prisma/reset.ts", 14 | "postinstall": "prisma generate" 15 | }, 16 | "dependencies": { 17 | "@clerk/nextjs": "^5.0.3", 18 | "@clerk/themes": "^2.1.0", 19 | "@hookform/resolvers": "^3.3.4", 20 | "@iconify/react": "^4.1.1", 21 | "@prisma/client": "^5.13.0", 22 | "@radix-ui/react-avatar": "^1.0.4", 23 | "@radix-ui/react-dialog": "^1.0.5", 24 | "@radix-ui/react-dropdown-menu": "^2.0.6", 25 | "@radix-ui/react-label": "^2.0.2", 26 | "@radix-ui/react-menubar": "^1.0.4", 27 | "@radix-ui/react-progress": "^1.0.3", 28 | "@radix-ui/react-scroll-area": "^1.0.5", 29 | "@radix-ui/react-separator": "^1.0.3", 30 | "@radix-ui/react-slot": "^1.0.2", 31 | "@radix-ui/react-switch": "^1.0.3", 32 | "@radix-ui/react-tooltip": "^1.0.7", 33 | "@tanstack/react-table": "^8.16.0", 34 | "class-variance-authority": "^0.7.0", 35 | "clsx": "^2.1.1", 36 | "cmdk": "^1.0.0", 37 | "date-fns": "^3.6.0", 38 | "firebase": "^10.11.1", 39 | "lucide-react": "^0.376.0", 40 | "next": "14.2.3", 41 | "next-themes": "^0.3.0", 42 | "react": "^18", 43 | "react-confetti": "^6.1.0", 44 | "react-dom": "^18", 45 | "react-hook-form": "^7.51.4", 46 | "react-switch": "^7.0.0", 47 | "react-syntax-highlighter": "^15.5.0", 48 | "react-use": "^17.5.0", 49 | "sonner": "^1.4.41", 50 | "svix": "^1.21.0", 51 | "tailwind-merge": "^2.3.0", 52 | "tailwindcss-animate": "^1.0.7", 53 | "zod": "^3.23.6", 54 | "zustand": "^4.5.2" 55 | }, 56 | "devDependencies": { 57 | "@types/node": "^20", 58 | "@types/react": "^18", 59 | "@types/react-dom": "^18", 60 | "@types/react-syntax-highlighter": "^15.5.13", 61 | "eslint": "^8", 62 | "eslint-config-next": "14.2.3", 63 | "postcss": "^8", 64 | "prisma": "^5.13.0", 65 | "tailwindcss": "^3.4.1", 66 | "tsx": "^4.7.3", 67 | "typescript": "^5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/profile-view-avatars.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { UserProfileViewWithViewers } from "@/types"; 4 | import React from "react"; 5 | import ActionTooltip from "./action-tooltip"; 6 | import Link from "next/link"; 7 | import { Avatar, AvatarImage } from "./ui/avatar"; 8 | import { useUserProfileViewAvatarModalStore } from "@/store/use-user-profile-view-avatar-modal-store"; 9 | import { MoreHorizontal } from "lucide-react"; 10 | 11 | type ProfileViewAvatarsProps = { 12 | userProfileViews: UserProfileViewWithViewers[]; 13 | }; 14 | 15 | const ProfileViewAvatars = ({ userProfileViews }: ProfileViewAvatarsProps) => { 16 | const { open, setUserProfileViews } = useUserProfileViewAvatarModalStore(); 17 | 18 | return ( 19 |
20 |

View By

21 | 22 | {userProfileViews?.length ? ( 23 |
24 | {userProfileViews.slice(0, 10).map((pv) => ( 25 |
29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 |
40 | ))} 41 |
42 | 43 | { 45 | setUserProfileViews(userProfileViews); 46 | open(); 47 | }} 48 | className="cursor-pointer w-10 h-10 rounded-full p-2 bg-slate-200 dark:bg-slate-800" 49 | /> 50 | 51 |
52 |
53 | ) : ( 54 |

No profile views.

55 | )} 56 |
57 | ); 58 | }; 59 | 60 | export default ProfileViewAvatars; 61 | -------------------------------------------------------------------------------- /src/components/modal/profile-view-avatar-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useEffect, useState } from "react"; 3 | 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog"; 10 | 11 | import Link from "next/link"; 12 | import { Avatar, AvatarImage } from "../ui/avatar"; 13 | import { useUserProfileViewAvatarModalStore } from "@/store/use-user-profile-view-avatar-modal-store"; 14 | import { ScrollArea } from "../ui/scroll-area"; 15 | 16 | const ProfileViewAvatarModal = () => { 17 | const [isClient, setIsClient] = useState(false); 18 | const { isOpen, close, userProfileViews } = 19 | useUserProfileViewAvatarModalStore(); 20 | 21 | useEffect(() => setIsClient(true), []); 22 | 23 | if (!isClient) { 24 | return null; 25 | } 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | Users who viewed your profile 33 | 34 | 35 | {userProfileViews?.length ? ( 36 | 37 | {userProfileViews.slice(0, 10).map((pv) => ( 38 |
39 | { 42 | close(); 43 | }} 44 | className="rounded-lg w-full flex items-center gap-x-2 py-3 px-5 bg-slate-200 dark:bg-slate-800 hover:bg-slate-100 hover:dark:bg-slate-700" 45 | > 46 | 47 | 51 | 52 |

{pv.viewer?.username}

53 | 54 |
55 | ))} 56 |
57 | ) : ( 58 |

No profile views.

59 | )} 60 |
61 |
62 | ); 63 | }; 64 | 65 | export default ProfileViewAvatarModal; 66 | -------------------------------------------------------------------------------- /src/app/(challenges)/_components/finished-screen.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import React, { useEffect } from "react"; 3 | import ResultCard from "./result-card"; 4 | import Footer from "./footer"; 5 | import { useRouter } from "next/navigation"; 6 | import Confetti from "react-confetti"; 7 | import { useAudio, useWindowSize } from "react-use"; 8 | import { ChallengeWithChallengeProgress } from "@/types"; 9 | 10 | type FinishedScreenProps = { 11 | challenges: ChallengeWithChallengeProgress[]; 12 | hearts: number; 13 | firstChallengeId?: number; 14 | }; 15 | 16 | const FinishedScreen = ({ 17 | challenges, 18 | hearts, 19 | firstChallengeId, 20 | }: FinishedScreenProps) => { 21 | const router = useRouter(); 22 | const { width, height } = useWindowSize(); 23 | const [finishAudio, _, control] = useAudio({ src: "/finish.mp3" }); 24 | 25 | useEffect(() => { 26 | control.play(); 27 | }, []); 28 | 29 | return ( 30 | <> 31 | {finishAudio} 32 | 39 |
40 | love image 47 | love image 54 |

55 | Great job!
You've completed the lesson. 56 |

57 |
58 | 59 | 60 |
61 |
62 |