├── app ├── actions │ └── actions.ts ├── favicon.ico ├── error │ └── page.tsx ├── ~offline │ └── page.tsx ├── community │ ├── page.tsx │ └── layout.tsx ├── api │ ├── scrape │ │ └── route.ts │ └── ai │ │ ├── questionHelper │ │ └── route.ts │ │ ├── teacher │ │ └── route.ts │ │ └── createLevels │ │ └── route.ts ├── complete │ └── [type] │ │ └── [id] │ │ ├── page.tsx │ │ ├── vote │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── battle │ │ └── page.tsx │ │ ├── rank │ │ └── page.tsx │ │ └── stats │ │ └── page.tsx ├── course │ ├── new │ │ └── [userId] │ │ │ └── page.tsx │ ├── layout.tsx │ └── edit │ │ └── [course] │ │ └── page.tsx ├── auth │ ├── signup │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── course │ │ └── page.tsx │ ├── layout.tsx │ └── actions.ts ├── training │ ├── actions.ts │ └── [trainingId] │ │ ├── page.tsx │ │ └── complete │ │ └── page.tsx ├── manifest.json ├── level │ ├── layout.tsx │ ├── new │ │ ├── ai │ │ │ └── page.tsx │ │ └── page.tsx │ ├── edit │ │ └── [level] │ │ │ └── page.tsx │ └── [level] │ │ └── page.tsx ├── page.tsx ├── leaderboard │ └── page.tsx ├── globals.css └── layout.tsx ├── .eslintrc.json ├── public ├── screenshots │ └── 01.png └── icons │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── android-chrome-192x192.png │ └── android-chrome-384x384.png ├── postcss.config.mjs ├── types ├── ai.ts ├── auth.ts └── client.ts ├── components ├── ui │ ├── Dot.tsx │ ├── ConditionalLink.tsx │ ├── PushNotification.tsx │ ├── Xp.tsx │ ├── Icon.tsx │ ├── separator.tsx │ ├── toaster.tsx │ ├── Header.tsx │ ├── Navigation.tsx │ ├── FollowButton.tsx │ ├── button.tsx │ ├── card.tsx │ ├── UppyFileUpload.tsx │ ├── drawer.tsx │ └── Streak.tsx ├── training │ ├── TrainingComplete.tsx │ ├── TrainButton.tsx │ ├── TrainingCard.tsx │ ├── ViewBattles.tsx │ ├── FriendlistAutocomplete.tsx │ ├── TrainingCompleteMain.tsx │ ├── ViewWeakQuestions.tsx │ ├── ViewTrainings.tsx │ └── WeeklyGoal.tsx ├── level │ ├── complete │ │ ├── LevelCompleteRank.tsx │ │ ├── Streak.tsx │ │ ├── LevelCompleteBattles.tsx │ │ ├── LevelCompleteContinueButton.tsx │ │ ├── LevelCompleteVote.tsx │ │ ├── LevelVoteButton.tsx │ │ └── LevelCompleteStats.tsx │ └── question │ │ ├── QuestionHeader.tsx │ │ ├── QuestionReportButton.tsx │ │ ├── Option.tsx │ │ └── questionTypes │ │ ├── EditBoolean.tsx │ │ ├── EditFillBlank.tsx │ │ ├── EditMultipleChoice.tsx │ │ └── EditMatchCards.tsx ├── user │ ├── ShareProfileButton.tsx │ ├── UserFriends.tsx │ ├── Username.tsx │ ├── ProfileStreak.tsx │ ├── XPChart.tsx │ ├── Settings.tsx │ ├── LeaderBoardCard.tsx │ ├── FindFriendsButton.tsx │ ├── EditProfileCard.tsx │ └── ProfileCard.tsx ├── course │ ├── CourseButton.tsx │ ├── ShareCourseButton.tsx │ ├── UserCourseOverview.tsx │ ├── CourseSelectSwiper.tsx │ ├── Courses.tsx │ ├── CoursesShowcaseSwiper.tsx │ ├── CourseAutocomplete.tsx │ ├── EditCourseCategory.tsx │ └── CourseCategoryAutocomplete.tsx ├── teacher │ ├── TeacherButton.tsx │ └── Message.tsx ├── levelScroller │ ├── CourseSectionBanner.tsx │ └── AddContentModal.tsx ├── utils │ └── Button.tsx ├── community │ └── CommunityMain.tsx ├── courseSection │ └── CourseSectionAutocomplete.tsx ├── auth │ ├── DeleteAccountButton.tsx │ └── SelectFirstCourse.tsx └── leaderboard │ └── LeaderboardScroller.tsx ├── utils ├── supabase │ ├── client.ts │ ├── xp.ts │ ├── reports.ts │ ├── settings.ts │ ├── server │ │ ├── server.ts │ │ └── middleware.ts │ ├── battles.ts │ ├── ranks.ts │ ├── weeklyGoals.ts │ ├── courseSections.ts │ ├── trainings.ts │ ├── streaks.ts │ └── questions.ts ├── utils.ts ├── question_types.ts ├── indexedDB │ ├── courses.ts │ ├── indexedDB.ts │ └── topics.ts └── functions │ └── ai │ └── schemas.ts ├── next.config.mjs ├── worker └── index.ts ├── components.json ├── .gitignore ├── middleware.ts ├── tsconfig.json ├── context ├── SharedUserCourses.tsx └── SharedCourse.tsx └── tailwind.config.ts /app/actions/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /public/screenshots/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/screenshots/01.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /app/error/page.tsx: -------------------------------------------------------------------------------- 1 | export default function ErrorPage() { 2 | return

Sorry, something went wrong

3 | } -------------------------------------------------------------------------------- /public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/icons/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cr4yfish/nouv/HEAD/public/icons/android-chrome-384x384.png -------------------------------------------------------------------------------- /app/~offline/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default function Offline() { 4 | return ( 5 | <> 6 |

This is offline fallback page test

7 | 8 | ); 9 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /types/ai.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Provider = { 3 | name: string; 4 | apiKey?: string; 5 | } 6 | 7 | export type Model = { 8 | model: string; 9 | provider: Provider; 10 | } -------------------------------------------------------------------------------- /components/ui/Dot.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | export default function Dot() { 4 | 5 | return ( 6 | <> 7 |
8 | 9 | ) 10 | } -------------------------------------------------------------------------------- /utils/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from '@supabase/ssr' 2 | 3 | export function getClient() { 4 | return createBrowserClient( 5 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 6 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 7 | ) 8 | } -------------------------------------------------------------------------------- /app/community/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import CommunityMain from "@/components/community/CommunityMain"; 4 | 5 | export default async function Community() { 6 | 7 | 8 | return ( 9 | <> 10 | 11 | 12 | 13 | ) 14 | } -------------------------------------------------------------------------------- /app/community/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Navigation from "@/components/ui/Navigation"; 3 | 4 | export default function Layout({ 5 | children, 6 | }: Readonly<{ 7 | children: React.ReactNode; 8 | }>) { 9 | 10 | return ( 11 | <> 12 | {children} 13 | 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /utils/supabase/xp.ts: -------------------------------------------------------------------------------- 1 | "use sever"; 2 | 3 | import { cache } from "react"; 4 | import { createClient as getClient } from "./server/server"; 5 | 6 | 7 | export const getXP = cache(async () => { 8 | const { data, error } = await getClient() 9 | .from("user_xp") 10 | .select("*") 11 | if(error) { throw error; } 12 | return data; 13 | }) -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import withPWAInit from "@ducanh2912/next-pwa"; 2 | 3 | const withPWA = withPWAInit({ 4 | dest: "public", 5 | cacheOnFrontEndNav: true, 6 | aggressiveFrontEndNavCaching: true, 7 | cacheStartUrl: true, 8 | dynamicStartUrl: true, 9 | dynamicStartUrlRedirect: "/auth" 10 | }); 11 | 12 | export default withPWA({ 13 | // Your Next.js config 14 | }); -------------------------------------------------------------------------------- /worker/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | self.addEventListener('push', function(event: any) { 4 | const data = (event).data.json(); 5 | const options = { 6 | body: data.body, 7 | icon: '/icon.png', 8 | badge: '/badge.png' 9 | }; 10 | event.waitUntil( 11 | (self as any).registration.showNotification(data.title, options) 12 | ); 13 | }); -------------------------------------------------------------------------------- /app/api/scrape/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(req: Request) { 2 | try { 3 | const { url } = await req.json(); 4 | 5 | const res = await fetch(url); 6 | 7 | const html = await res.text(); 8 | 9 | return Response.json({ 10 | data: html 11 | }) 12 | 13 | } catch (e) { 14 | console.error(e); 15 | return Response.json({ 16 | data: e 17 | }) 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { redirect } from "next/navigation"; 3 | 4 | 5 | type Params = { 6 | params : { 7 | type : string; 8 | id : string; 9 | } 10 | } 11 | 12 | export default async function Complete(params: Params) { 13 | const { type, id } = params.params; 14 | 15 | 16 | // TODO - check if user has completed this level 17 | 18 | // if so, then show the stats 19 | redirect(`/complete/${type}/${id}/stats`); 20 | } -------------------------------------------------------------------------------- /app/api/ai/questionHelper/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { explainAnswer } from "@/utils/functions/ai/ai"; 3 | 4 | export async function POST(req: Request) { 5 | const {messages} = await req.json(); 6 | 7 | // headers 8 | //const apiKey = req.headers.get("X-api-key"); 9 | const apiKey = process.env.MISTRAL_API_KEY!; 10 | 11 | const result = await explainAnswer({ 12 | apiKey: apiKey, 13 | messages: messages 14 | }); 15 | 16 | return result.toDataStreamResponse(); 17 | } -------------------------------------------------------------------------------- /types/auth.ts: -------------------------------------------------------------------------------- 1 | 2 | import { User, Session } from "@supabase/supabase-js"; 3 | import { Profile, Settings, Streak, User_Course } from "./db"; 4 | 5 | 6 | export interface SessionState { 7 | user: User | undefined; 8 | profile: Profile | undefined; 9 | session: Session | null; 10 | isLoggedIn: boolean; 11 | pendingAuth: boolean; 12 | settings: Settings; 13 | courses: User_Course[]; 14 | currentStreak: Streak | undefined; 15 | currentStreakDays: number | undefined; 16 | } -------------------------------------------------------------------------------- /utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | 9 | export const framerListAnimationProps = { 10 | initial: "hidden", 11 | animate: "visible", 12 | variants: { 13 | visible: (i: number) => ({ 14 | opacity: 1, 15 | y: 0, 16 | transition: { 17 | delay: i * 0.05, 18 | }, 19 | }), 20 | hidden: { opacity: 0, y: -10 }, 21 | }} -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /components/ui/ConditionalLink.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Link from "next/link" 3 | 4 | export default function ConditionalLink({ children, href, active } : { children: React.ReactNode, href: string, active: boolean }) { 5 | if(active) { 6 | return ( 7 | 11 | {children} 12 | 13 | ) 14 | } else { 15 | return ( 16 | <> 17 | {children} 18 | 19 | ) 20 | } 21 | } -------------------------------------------------------------------------------- /components/ui/PushNotification.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from 'react'; 4 | 5 | const PushNotification = () => { 6 | useEffect(() => { 7 | if ('Notification' in window && navigator.serviceWorker) { 8 | Notification.requestPermission().then(permission => { 9 | if (permission === 'granted') { 10 | console.log('Notification permission granted.'); 11 | } else { 12 | console.log('Notification permission denied.'); 13 | } 14 | }); 15 | } 16 | }, []); 17 | 18 | return null; 19 | }; 20 | 21 | export default PushNotification; -------------------------------------------------------------------------------- /components/training/TrainingComplete.tsx: -------------------------------------------------------------------------------- 1 | 2 | import LevelCompleteStats from "../level/complete/LevelCompleteStats" 3 | import { Training } from "@/types/db" 4 | 5 | export default function TrainingComplete({ training } : { training: Training }) { 6 | 7 | if(!training.xp || !training.seconds || !training.accuracy) return <> 8 | 9 | return ( 10 | <> 11 |
12 | 13 | 14 |
15 | 16 | ) 17 | } -------------------------------------------------------------------------------- /app/course/new/[userId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import NewCourseMain from "@/components/course/NewCourseMain"; 4 | 5 | export default async function NewCourse({ params: { userId }} : { params: { userId: string }}) { 6 | 7 | 8 | if(!userId) { 9 | return ( 10 | <> 11 |
12 |

You have to provide a user id!

13 |
14 | 15 | ) 16 | } 17 | 18 | return ( 19 |
20 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # PWA 39 | **/public/workbox-*.js 40 | **/public/sw.js 41 | **/public/fallback-*.js 42 | .vscode/settings.json 43 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest } from 'next/server' 2 | import { updateSession } from '@/utils/supabase/server/middleware' 3 | 4 | export async function middleware(request: NextRequest) { 5 | return await updateSession(request) 6 | } 7 | 8 | export const config = { 9 | matcher: [ 10 | /* 11 | * Match all request paths except for the ones starting with: 12 | * - _next/static (static files) 13 | * - _next/image (image optimization files) 14 | * - favicon.ico (favicon file) 15 | * Feel free to modify this pattern to include more paths. 16 | */ 17 | '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', 18 | ], 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 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "worker/index.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /app/api/ai/teacher/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createMistral } from '@ai-sdk/mistral'; 3 | import { streamText } from 'ai'; 4 | 5 | // Allow streaming responses up to 30 seconds 6 | export const maxDuration = 30; 7 | 8 | export async function POST(req: Request) { 9 | const { messages } = await req.json(); 10 | 11 | const apiKey = process.env.MISTRAL_API_KEY!; 12 | const mistral = createMistral({ apiKey: apiKey }); 13 | 14 | const result = await streamText({ 15 | model: mistral("open-mistral-nemo"), 16 | system: "You are a helpful teacher. You only answer questions related to the course. Your answer has to be in markdown format.", 17 | messages 18 | }) 19 | 20 | return result.toDataStreamResponse(); 21 | } -------------------------------------------------------------------------------- /app/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | 4 | import { Link as NextUILink } from "@nextui-org/link"; 5 | 6 | import { getUser } from "@/utils/supabase/auth"; 7 | import { redirect } from "next/navigation"; 8 | import SignupCard from "@/components/auth/SignupCard"; 9 | 10 | export default async function Signup() { 11 | 12 | const session = await getUser(); 13 | 14 | if(session.data.user?.id) { 15 | redirect("/"); 16 | } 17 | 18 | return ( 19 |
20 |

Welcome to Nouv!

21 | 22 | Login instead 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /components/level/complete/LevelCompleteRank.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { framerListAnimationProps } from "@/utils/utils"; 4 | import { motion } from "framer-motion"; 5 | 6 | export default function LevelCompleteRank({ rankTitle } : { rankTitle: string }) { 7 | 8 | return ( 9 |
10 | You ranked up! 11 | {rankTitle} 12 | New rank 13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /components/ui/Xp.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@nextui-org/skeleton"; 2 | import { Button } from "@nextui-org/button"; 3 | 4 | import Icon from "./Icon" 5 | 6 | export default function Xp({ xp, isLoaded } : { xp?: number, isLoaded: boolean }) { 7 | 8 | return ( 9 | <> 10 | 11 | 18 | 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /app/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import Link from "next/link" 4 | 5 | 6 | import { getUser } from "@/utils/supabase/auth" 7 | import { redirect } from "next/navigation" 8 | import SignupCard from "@/components/auth/SignupCard"; 9 | 10 | export default async function Login() { 11 | 12 | const session = await getUser(); 13 | 14 | if(session.data.user?.id) { 15 | redirect("/"); 16 | } 17 | 18 | return ( 19 |
20 |

Welcome back!

21 | 22 | Sign up instead 23 |
24 | ) 25 | } -------------------------------------------------------------------------------- /components/ui/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import "@material-symbols/font-400"; 4 | 5 | export default function Icon( 6 | { children, color="inherit", darkColor, filled, upscale, downscale } : 7 | { children: React.ReactNode, color?: string, darkColor?: string; filled?: boolean, upscale?: boolean, downscale?: boolean }) { 8 | return ( 9 | 18 | {children} 19 | 20 | ) 21 | } -------------------------------------------------------------------------------- /components/training/TrainButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Weak_User_Questions } from "@/types/db"; 6 | import { Button } from "../utils/Button"; 7 | import Icon from "@/components/ui/Icon"; 8 | 9 | import { createTrainingLevel } from "@/app/training/actions"; 10 | 11 | export default function TrainButton({ weakQuestions } : { weakQuestions: Weak_User_Questions[] }) { 12 | const [isLoading, setIsLoading] = useState(false); 13 | 14 | return ( 15 | 23 | ) 24 | } -------------------------------------------------------------------------------- /app/training/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | import { Weak_User_Questions } from "@/types/db"; 6 | import { addTraining } from "@/utils/supabase/trainings"; 7 | import { getUser } from "@/utils/supabase/auth"; 8 | import { shuffleArray } from "@/utils/functions/helpers"; 9 | 10 | 11 | export async function createTrainingLevel(weakQuestions: Weak_User_Questions[]) { 12 | 13 | const { data: { user } } = await getUser(); 14 | 15 | if(!user?.id) { 16 | return; 17 | } 18 | 19 | const randomizedQuestions = shuffleArray(weakQuestions); 20 | 21 | const res = await addTraining({ 22 | userId: user.id, 23 | questions: randomizedQuestions.slice(0,5).map((wk) => wk.question) 24 | }) 25 | 26 | // redirect user to the new level 27 | redirect(`/training/${res.id}`); 28 | 29 | } -------------------------------------------------------------------------------- /app/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nouv - Learn better", 3 | "short_name": "Nouv", 4 | "icons": [ 5 | { 6 | "src": "/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icons/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/icon-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "screenshots": [ 22 | { 23 | "src": "/screenshots/01.png", 24 | "sizes": "1290x2796", 25 | "type": "image/png" 26 | } 27 | ], 28 | "theme_color": "#FFFFFF", 29 | "background_color": "#FFFFFF", 30 | "start_url": "/", 31 | "display": "standalone", 32 | "orientation": "portrait" 33 | } -------------------------------------------------------------------------------- /components/level/complete/Streak.tsx: -------------------------------------------------------------------------------- 1 | export default function LevelCompleteStreak({ streakDays } : { streakDays: number }) { 2 | 3 | return ( 4 |
5 | 13 | mode_heat 14 | 15 | {streakDays} 19 | day streak 20 |
21 | ) 22 | } -------------------------------------------------------------------------------- /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 "@/utils/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 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /utils/question_types.ts: -------------------------------------------------------------------------------- 1 | import { Question_Type } from "@/types/db"; 2 | 3 | 4 | export const QuestionTypes: Record<"multiple_choice" | "boolean" | "fill_in_the_blank" | "match_the_words", Question_Type> = { 5 | "multiple_choice": { 6 | id: "5570443a-63bb-4158-b86a-a2cef3457cf0", 7 | title: "Multiple Choice", 8 | description: "Select the correct answers from a list of options.", 9 | }, 10 | "boolean": { 11 | id: "33b2c6e5-df24-4812-a042-b5bed4583bc0", 12 | title: "Boolean", 13 | description: "True or False?", 14 | }, 15 | "fill_in_the_blank": { 16 | id: "6335b9a6-2722-4ece-a142-4749f57e6fed", 17 | title: "Fill in the blank", 18 | description: "Fill in the blank with the correct answer.", 19 | }, 20 | "match_the_words": { 21 | id: "7babe7ed-3e4c-408d-87e2-0420877d34c9", 22 | title: "Match the Cards", 23 | description: "Match Cards" 24 | } 25 | } -------------------------------------------------------------------------------- /utils/indexedDB/courses.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Course } from "@/types/db"; 4 | import { cache } from "react"; 5 | 6 | import { openDB } from "./indexedDB"; 7 | 8 | export const saveCourseToLocal = async (course: Course) => { 9 | const db = await openDB(); 10 | 11 | const transaction = db.transaction("courses", "readwrite") 12 | const store = transaction.objectStore("courses"); 13 | 14 | const request = store.put(course); 15 | 16 | request.onerror = function(event) { 17 | console.error(event); 18 | } 19 | 20 | return request; 21 | } 22 | 23 | export const getLocalCourses = cache(async () => { 24 | const db = await openDB(); 25 | const transaction = db.transaction("courses", "readonly"); 26 | const store = transaction.objectStore("courses"); 27 | 28 | const request = store.getAll(); 29 | 30 | request.onerror = function(event) { 31 | console.error(event); 32 | } 33 | 34 | return request; 35 | }) -------------------------------------------------------------------------------- /components/user/ShareProfileButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@nextui-org/button"; 4 | import Icon from "@/components/ui/Icon"; 5 | 6 | type Params = { 7 | userId: string; 8 | } 9 | 10 | export default function ShareProfileButton(params: Params) { 11 | 12 | 13 | return ( 14 | 32 | ) 33 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/vote/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import LevelCompleteContinueButton from "@/components/level/complete/LevelCompleteContinueButton"; 4 | import LevelCompleteVote from "@/components/level/complete/LevelCompleteVote"; 5 | import { getSession } from "@/utils/supabase/auth"; 6 | import { redirect } from "next/navigation"; 7 | 8 | 9 | type Params = { 10 | params : { 11 | type : "level" | "training"; 12 | id : string; 13 | } 14 | } 15 | 16 | export default async function CompleteVote({params}: Params) { 17 | 18 | if(params.type === "training") { 19 | redirect("/") 20 | } 21 | 22 | const { data: { session } } = await getSession(); 23 | 24 | if(!session?.user.id) { 25 | redirect("/auth"); 26 | } 27 | 28 | return ( 29 | <> 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } -------------------------------------------------------------------------------- /components/level/complete/LevelCompleteBattles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { framerListAnimationProps } from "@/utils/utils"; 4 | import { Battle, Profile } from "@/types/db"; 5 | import { motion } from "framer-motion"; 6 | 7 | import Battles from "@/components/training/Battles"; 8 | 9 | type Props = { 10 | userId : string; 11 | userProfile : Profile; 12 | battles : Battle[]; 13 | } 14 | 15 | export default function LevelCompleteBattles(props: Props) { 16 | 17 | return ( 18 | <> 19 | 24 | Your Battles 25 | 26 | 30 | 31 | 32 | 33 | ) 34 | } -------------------------------------------------------------------------------- /components/level/question/QuestionHeader.tsx: -------------------------------------------------------------------------------- 1 | 2 | import Link from "next/link"; 3 | import { Progress } from "@nextui-org/progress"; 4 | import { Button } from "@/components/utils/Button"; 5 | 6 | import Icon from "@/components/ui/Icon"; 7 | import Xp from "@/components/ui/Xp"; 8 | 9 | export default function QuestionHeader({ progress, xp, numQuestions, show } : { progress: number, xp: number, numQuestions: number, show: boolean }) { 10 | 11 | return ( 12 | <> 13 | { show && 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 | } 22 | 23 | ) 24 | 25 | } -------------------------------------------------------------------------------- /types/client.ts: -------------------------------------------------------------------------------- 1 | 2 | export type LevelState = { 3 | progress: number, 4 | answeredQuestions: number, 5 | totalQuestions: number, 6 | correctQuestions: number; 7 | xp: number, 8 | currentQuestionIndex: number, 9 | seconds: number, 10 | rankUp: boolean, 11 | questions: { 12 | id: string; 13 | completed: boolean; 14 | }[] 15 | } 16 | 17 | export type Correct = "correct" | "wrong" | "initial" 18 | 19 | export type QuestionState = { 20 | options: string[], 21 | answers: string[], 22 | selected: string[], 23 | correct: Correct 24 | } 25 | 26 | export type Match = { 27 | option: string, 28 | match: string, 29 | correct: Correct 30 | } 31 | 32 | export type MatchCardsState = { 33 | options: string[], 34 | matches: Match[], 35 | } 36 | 37 | 38 | export type FillBlankState = { 39 | selected: { 40 | word: string, 41 | index: number 42 | }[], 43 | nextIndex: number, 44 | } 45 | 46 | export type OptionState = "selected" | "unselected" | "correct" | "wrong" -------------------------------------------------------------------------------- /components/course/CourseButton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card" 2 | 3 | import { Course } from "@/types/db"; 4 | 5 | export default function CourseButton({ course, active, onPress } : { course: Course | undefined, active: boolean, onPress: () => void }) { 6 | 7 | return ( 8 | <> 9 | 16 | 17 | {course?.abbreviation} 18 | {course?.description} 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /components/level/complete/LevelCompleteContinueButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link" 4 | import { motion } from "framer-motion" 5 | 6 | import { Button } from "@/components/utils/Button" 7 | import Icon from "@/components/ui/Icon"; 8 | import { framerListAnimationProps } from "@/utils/utils"; 9 | 10 | type Props = { 11 | type: "level" | "training"; 12 | id: string; 13 | next: "stats" | "battle" | "rank" | "vote" | "done"; 14 | rankUp?: boolean; 15 | listNumber: number; 16 | } 17 | 18 | export default function LevelCompleteContinueButton(props: Props) { 19 | 20 | return ( 21 | 25 | 26 | 27 | 28 | 29 | ) 30 | } -------------------------------------------------------------------------------- /components/user/UserFriends.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getFollowers, getFriends } from "@/utils/supabase/user"; 4 | import { Suspense } from "react"; 5 | 6 | type Params = { 7 | userId: string; 8 | } 9 | 10 | export default async function UserFriends(params: Params) { 11 | 12 | const friends = await getFriends(params.userId); 13 | const followers = await getFollowers(params.userId); 14 | 15 | 16 | return ( 17 | <> 18 | ..Loading friends}> 19 |
20 | {friends.length} 21 | Following 22 |
23 | 24 |
25 | {followers.length} 26 | Followers 27 |
28 |
29 | 30 | ) 31 | 32 | } 33 | -------------------------------------------------------------------------------- /app/api/ai/createLevels/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createLevelFromDocument } from "@/utils/functions/ai/ai"; 3 | 4 | export async function POST(req: Request) { 5 | //const context = await req.json(); 6 | 7 | // headers 8 | const apiKey = req.headers.get("X-api-key"); 9 | const docName = req.headers.get("X-doc-name"); 10 | const numLevels = req.headers.get("X-num-levels"); 11 | const courseSectionTitle = req.headers.get("X-course-section-title"); 12 | const courseSectionDescription = req.headers.get("X-course-section-description"); 13 | 14 | if(apiKey === null || docName === null || apiKey.length == 0 || docName.length == 0) { 15 | return new Response("Missing API key or doc name", {status: 400}); 16 | } 17 | 18 | const result = await createLevelFromDocument({ 19 | docName: docName, 20 | apiKey: apiKey, 21 | numLevels: numLevels ? parseInt(numLevels) : 1, 22 | courseSectionTitle: courseSectionTitle || "", 23 | courseSectionDescription: courseSectionDescription || "" 24 | }); 25 | 26 | 27 | return result.toTextStreamResponse(); 28 | } -------------------------------------------------------------------------------- /context/SharedUserCourses.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User_Course } from '@/types/db'; 4 | import { createContext, useContext, useState, ReactNode } from 'react'; 5 | 6 | interface SharedStateContextType { 7 | userCourses: User_Course[]; 8 | setUserCourses: (value: User_Course[]) => void; 9 | } 10 | 11 | const SharedStateContext = createContext(undefined); 12 | 13 | export const UserCoursesProvider = ({ children }: { children: ReactNode }) => { 14 | const [userCourses, setUserCourses] = useState([]); 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export const useUserCourses = (initUserCourses?: User_Course[]) => { 24 | const context = useContext(SharedStateContext); 25 | 26 | if(initUserCourses) { 27 | context?.setUserCourses(initUserCourses) 28 | } 29 | 30 | 31 | if (!context) { 32 | throw new Error('useUserCourses must be used within a UserCoursesProvider'); 33 | } 34 | return context; 35 | }; -------------------------------------------------------------------------------- /context/SharedCourse.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // context/SharedStateContext.tsx 4 | import { Course } from '@/types/db'; 5 | import { createContext, useContext, useState, ReactNode } from 'react'; 6 | 7 | interface SharedStateContextType { 8 | currentCourse: Course | null; 9 | setCurrentCourse: (value: Course | null) => void; 10 | } 11 | 12 | const SharedStateContext = createContext(undefined); 13 | 14 | export const CurrentCourseProvider = ({ children }: { children: ReactNode }) => { 15 | const [currentCourse, setCurrentCourse] = useState(null); 16 | 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | }; 23 | 24 | export const useCurrentCourse = (initCourse?: Course) => { 25 | const context = useContext(SharedStateContext); 26 | 27 | if(initCourse) { 28 | context?.setCurrentCourse(initCourse); 29 | } 30 | if (!context) { 31 | throw new Error('useCurrentCourse must be used within a CurrentCourseProvider'); 32 | } 33 | return context; 34 | }; -------------------------------------------------------------------------------- /app/course/layout.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import React from "react"; 4 | 5 | type Props = { 6 | children: React.ReactNode | React.ReactNode[]; 7 | } 8 | 9 | export default async function Layout({ children } : Props) { 10 | 11 | return ( 12 | <> 13 | {children} 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /app/level/layout.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import React from "react"; 4 | 5 | type Props = { 6 | children: React.ReactNode | React.ReactNode[]; 7 | } 8 | 9 | export default async function Layout({ children } : Props) { 10 | 11 | return ( 12 | <> 13 | {children} 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } -------------------------------------------------------------------------------- /components/course/ShareCourseButton.tsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | "use client"; 4 | 5 | import { Button } from "@nextui-org/button"; 6 | import Icon from "../ui/Icon"; 7 | 8 | 9 | type Params = { 10 | courseId: string; 11 | showLabel?: boolean; 12 | } 13 | 14 | export default function ShareCourseButton(params: Params) { 15 | 16 | 17 | return ( 18 | 37 | ) 38 | } -------------------------------------------------------------------------------- /components/level/complete/LevelCompleteVote.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { framerListAnimationProps } from "@/utils/utils"; 4 | import { motion } from "framer-motion"; 5 | 6 | import LevelVoteButton from "./LevelVoteButton"; 7 | import { Session } from "@supabase/supabase-js"; 8 | 9 | type Props = { 10 | params: { 11 | id: string; 12 | }, 13 | session: Session; 14 | } 15 | 16 | export default function LevelCompleteVote(props: Props) { 17 | 18 | return ( 19 | <> 20 |
21 | Liked the Level? 22 | Voting helps out the creator 23 |
24 | 28 | 29 | 30 | 31 | ) 32 | } -------------------------------------------------------------------------------- /utils/indexedDB/indexedDB.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export const openDB = async (newVersion?: number) => { 4 | return new Promise((resolve, reject) => { 5 | const request = indexedDB.open("localDB", newVersion || 1); 6 | 7 | request.onsuccess = function(this) { 8 | resolve(this.result); 9 | } 10 | 11 | request.onerror = (event) => { 12 | reject(event); 13 | } 14 | 15 | request.onupgradeneeded = function (this) { 16 | const db = this.result; 17 | db.createObjectStore("courses", { keyPath: "id" }); 18 | db.createObjectStore("topics", { keyPath: "id" }); 19 | } 20 | 21 | }); 22 | } 23 | 24 | export const upgradeDB = async (): Promise => { 25 | const oldDB = await openDB(1); 26 | return await openDB(oldDB.version + 1); 27 | } 28 | 29 | export const clearDB = async (): Promise => { 30 | const db = await openDB(); 31 | const transaction = db.transaction(["courses", "topics"], "readwrite"); 32 | 33 | transaction.objectStore("courses").clear(); 34 | transaction.objectStore("topics").clear(); 35 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/layout.tsx: -------------------------------------------------------------------------------- 1 | 2 | type Params = { 3 | children : React.ReactNode; 4 | } 5 | 6 | export default async function Layout({ children }: Params) { 7 | 8 | return ( 9 |
10 | {children} 11 | 12 | 13 | 14 | 15 |
16 | ) 17 | } -------------------------------------------------------------------------------- /components/course/UserCourseOverview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | 4 | import { ScrollShadow } from "@nextui-org/scroll-shadow"; 5 | 6 | import CourseCard from "./CourseCard"; 7 | import { SessionState } from "@/types/auth"; 8 | 9 | import { useUserCourses } from "@/context/SharedUserCourses"; 10 | import { useEffect } from "react"; 11 | 12 | export default function UserCourseOverview({ sessionState } : { sessionState: SessionState }) { 13 | 14 | const { userCourses, setUserCourses } = useUserCourses(); 15 | 16 | useEffect(() => { 17 | setUserCourses(sessionState.courses); 18 | }, [sessionState, setUserCourses]) 19 | 20 | return ( 21 | <> 22 |

Your courses

23 | 24 | {userCourses?.map((userCourse) => ( 25 | 30 | ))} 31 | 32 | 33 | ) 34 | } -------------------------------------------------------------------------------- /components/user/Username.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { User } from "@nextui-org/user"; 4 | 5 | import { Profile } from "@/types/db"; 6 | import BlurModal from "../ui/BlurModal"; 7 | import ProfileCard from "./ProfileCard"; 8 | import { SessionState } from "@/types/auth"; 9 | 10 | 11 | type Props = { 12 | profile: Profile, 13 | sessionState: SessionState, 14 | isOpen: boolean, 15 | setOpen: React.Dispatch> 16 | } 17 | 18 | export default function Username(props: Props) { 19 | 20 | return ( 21 | <> 22 | 27 | 28 | {props.profile?.username}s' Profile} 37 | body={} 38 | footer={<>} 39 | /> 40 | 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /components/course/CourseSelectSwiper.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollShadow } from '@nextui-org/scroll-shadow'; 2 | 3 | import CourseButton from './CourseButton'; 4 | import { Course } from '@/types/db'; 5 | 6 | export default function CourseSelectSwiper({ 7 | courses, currentCourse, setCurrentCourse } : 8 | { 9 | courses: Course[], currentCourse: Course | undefined, 10 | setCurrentCourse: (course: Course) => void, 11 | }) { 12 | 13 | return ( 14 | 15 |
16 | {[currentCourse, ...courses.filter((course) => course.id !== currentCourse?.id )].map((course, index) => ( 17 | course && 18 | course && setCurrentCourse(course)} 21 | course={course} 22 | active={course?.id === currentCourse?.id} 23 | /> 24 | ))} 25 |
26 |
27 | ) 28 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/battle/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import LevelCompleteBattles from "@/components/level/complete/LevelCompleteBattles"; 4 | import LevelCompleteContinueButton from "@/components/level/complete/LevelCompleteContinueButton"; 5 | import { getUser } from "@/utils/supabase/auth"; 6 | import { getBattles } from "@/utils/supabase/battles"; 7 | import { getProfileById } from "@/utils/supabase/user"; 8 | import { redirect } from "next/navigation"; 9 | 10 | type Params = { 11 | params : { 12 | type : "level" | "training"; 13 | id : string; 14 | } 15 | } 16 | 17 | export default async function CompleteBattle(params: Params) { 18 | const { type, id } = params.params; 19 | 20 | // get battles and show progress 21 | 22 | const { data: { user } } = await getUser(); 23 | 24 | if(!user?.id) { 25 | redirect("/auth"); 26 | } 27 | 28 | const userProfile = await getProfileById(user.id); 29 | const battles = await getBattles(user.id); 30 | 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } -------------------------------------------------------------------------------- /components/level/complete/LevelVoteButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "@/components/utils/Button"; 6 | import Icon from "@/components/ui/Icon"; 7 | import { upvoteCourseTopic } from "@/utils/supabase/topics"; 8 | 9 | type Params = { 10 | userId: string; 11 | levelId: string; 12 | } 13 | 14 | export default function LevelVoteButton(params : Params) { 15 | const [isVoted, setIsVoted] = useState(false); 16 | const [isVoting, setIsVoting] = useState(false); 17 | 18 | const handleUpvoteLevel = async () => { 19 | setIsVoting(true); 20 | const res = await upvoteCourseTopic(params.levelId, params.userId); 21 | if(res) { 22 | setIsVoted(true); 23 | } 24 | 25 | setIsVoting(false); 26 | } 27 | 28 | return ( 29 | <> 30 | 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /app/auth/course/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | import SelectFirstCourse from "@/components/auth/SelectFirstCourse" 6 | 7 | import { getCurrentUser } from "@/utils/supabase/auth"; 8 | import { getCourses } from "@/utils/supabase/courses"; 9 | import { UserCoursesProvider } from "@/context/SharedUserCourses"; 10 | 11 | 12 | export default async function SelectCourse() { 13 | 14 | const sessionState = await getCurrentUser(); 15 | 16 | if(!sessionState) { 17 | redirect("/auth"); 18 | } 19 | 20 | const courses = await getCourses({ from: 0, limit: 10, orderBy: "course_votes_count", isAscending: false }); 21 | 22 | return ( 23 | <> 24 |
25 |

Join a course to get started

26 |

You can join or create more Courses later

27 |
28 | 29 | 30 | 35 | 36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /components/course/Courses.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import CoursesShowcaseSwiper from "./CoursesShowcaseSwiper"; 4 | 5 | import { SessionState } from "@/types/auth"; 6 | import UserCourseOverview from "./UserCourseOverview"; 7 | import { Separator } from "@/components/ui/separator"; 8 | 9 | import { getCourses } from "@/utils/supabase/courses"; 10 | 11 | export default async function Courses({ sessionState } : { sessionState: SessionState }) { 12 | 13 | const newestCourses = await getCourses({ from: 0, limit: 5, orderBy: 'created_at', isAscending: false }); 14 | const popularCourses = await getCourses({ from: 0, limit: 5, orderBy: 'course_votes_count', isAscending: false }); 15 | 16 | return ( 17 | <> 18 |
19 | 20 |
21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 |
29 | 30 | ) 31 | } -------------------------------------------------------------------------------- /components/training/TrainingCard.tsx: -------------------------------------------------------------------------------- 1 | import { Training } from "@/types/db" 2 | 3 | import { Card, CardHeader, CardDescription, CardContent } from "../ui/card" 4 | import Icon from "@/components/ui/Icon"; 5 | 6 | type Params = { 7 | training: Training 8 | } 9 | 10 | export default function TrainingCard(params: Params) { 11 | 12 | 13 | return ( 14 | <> 15 | 16 | 17 | 18 | {new Date(params.training.created_at ?? "").toLocaleDateString()} 19 | 20 | 21 | 22 | 23 |
24 | crisis_alert 25 | {params.training.accuracy} 26 |
27 |
28 | schedule 29 | {params.training.seconds} 30 |
31 |
32 | hotel_class 33 | {params.training.xp} 34 |
35 |
36 |
37 | 38 | ) 39 | } -------------------------------------------------------------------------------- /components/level/question/QuestionReportButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Question } from "@/types/db"; 6 | import { Button } from "@/components/utils/Button"; 7 | import Icon from "@/components/ui/Icon"; 8 | import { reportQuestion } from "@/utils/supabase/reports"; 9 | 10 | export default function QuestionReportButton({ question, userId }: { question: Question, userId?: string }) { 11 | const [isLoading, setIsLoading] = useState(false); 12 | const [isReported, setIsReported] = useState(false); 13 | 14 | 15 | const handleReport = async() => { 16 | if(!userId) return 17 | 18 | setIsLoading(true); 19 | 20 | try { 21 | await reportQuestion({ 22 | question: question.id, 23 | user: userId, 24 | }) 25 | setIsReported(true); 26 | } catch (e) { 27 | console.error(e) 28 | setIsLoading(false); 29 | } 30 | 31 | setIsLoading(false); 32 | } 33 | 34 | return ( 35 | 46 | ) 47 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/rank/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | 4 | import LevelCompleteRank from "@/components/level/complete/LevelCompleteRank"; 5 | import LevelCompleteContinueButton from "@/components/level/complete/LevelCompleteContinueButton"; 6 | import { getUser } from "@/utils/supabase/auth"; 7 | import { getCurrentUserRank } from "@/utils/supabase/ranks"; 8 | 9 | 10 | type Params = { 11 | params : { 12 | type : "level" | "training"; 13 | id : string; 14 | }, 15 | searchParams: { [key: string]: string } 16 | } 17 | 18 | export default async function CompleteRank(params: Params) { 19 | const { type, id } = params.params; 20 | const urlSearchParams = new URLSearchParams(params.searchParams); 21 | const rankUp = urlSearchParams.get("rankUp") === "true"; 22 | 23 | 24 | const { data: { user } } = await getUser(); 25 | 26 | if(!user?.id) { 27 | redirect("/auth"); 28 | } 29 | 30 | const currentRank = await getCurrentUserRank(); 31 | 32 | if(rankUp) { 33 | 34 | return ( 35 | <> 36 | 37 | 38 | 39 | ) 40 | } else { 41 | return ( 42 | redirect(`/complete/${type}/${id}/battle`) 43 | ) 44 | } 45 | } -------------------------------------------------------------------------------- /components/teacher/TeacherButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Button } from "../utils/Button"; 6 | import Icon from "@/components/ui/Icon"; 7 | import BlurModal from "../ui/BlurModal"; 8 | import TeacherChat from "./TeacherChat"; 9 | import { Course, Profile } from "@/types/db"; 10 | 11 | type TeacherButtonProps = { 12 | course: Course, 13 | userProfile: Profile, 14 | } 15 | 16 | export default function TeacherButton(props: TeacherButtonProps) { 17 | const [isModalOpen, setIsModalOpen] = useState(false); 18 | 19 | return ( 20 | <> 21 | 22 | Course Teacher} 32 | body={ 33 |
34 | 38 |
39 | } 40 | /> 41 | 42 | ) 43 | } -------------------------------------------------------------------------------- /app/level/new/ai/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import Link from "next/link"; 4 | import { redirect } from "next/navigation"; 5 | 6 | 7 | import { getCurrentUser } from "@/utils/supabase/auth"; 8 | 9 | import { Button } from "@/components/utils/Button"; 10 | import Icon from "@/components/ui/Icon"; 11 | import LevelNewAIMain from "@/components/level/new/ai/LevelNewAIMain"; 12 | import { getCourseById } from "@/utils/supabase/courses"; 13 | 14 | type Props = { 15 | searchParams: { [key: string]: string } 16 | } 17 | 18 | 19 | export default async function CreateLevelWithAI(props: Props) { 20 | const urlSearchParams = new URLSearchParams(props.searchParams); 21 | const courseId = urlSearchParams.get("courseId"); 22 | 23 | if(!courseId) { 24 | redirect("/"); 25 | } 26 | 27 | const course = await getCourseById(courseId); 28 | 29 | const session = await getCurrentUser(); 30 | 31 | if(!session) { 32 | redirect("/auth"); 33 | } 34 | 35 | return ( 36 | <> 37 |
38 |
39 | 40 |
41 | 42 | 43 | 44 | 45 |
46 | 47 | ) 48 | } -------------------------------------------------------------------------------- /components/user/ProfileStreak.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Profile } from "@/types/db"; 4 | import { useEffect, useState } from "react"; 5 | import { Spinner } from "@nextui-org/spinner"; 6 | 7 | import Icon from "@/components/ui/Icon"; 8 | import { getCurrentStreak } from "@/utils/supabase/streaks"; 9 | import { streakToStreakDays } from "@/utils/functions/helpers"; 10 | 11 | export default function ProfileStreak({ profile } : { profile: Profile }) { 12 | const [streakDays, setStreakDays] = useState(0); 13 | const [loading, setLoading] = useState(true); 14 | 15 | useEffect(() => { 16 | const fetchStreak = async () => { 17 | setLoading(true); 18 | const res = await getCurrentStreak(profile.id, new Date()); 19 | let currentStreakDays = 0; 20 | if(res) { 21 | currentStreakDays = streakToStreakDays(res); 22 | } 23 | setStreakDays(currentStreakDays); 24 | setLoading(false); 25 | } 26 | 27 | fetchStreak(); 28 | }, [profile]) 29 | 30 | return ( 31 | <> 32 |
33 |
34 | mode_heat 35 |
36 |
37 | {loading ? : streakDays} 38 |
39 |
40 | 41 | ) 42 | } -------------------------------------------------------------------------------- /components/training/ViewBattles.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Battle } from "@/types/db"; 6 | import { Button } from "../utils/Button"; 7 | import Icon from "@/components/ui/Icon"; 8 | import BlurModal from "../ui/BlurModal"; 9 | import BattleCard from "./BattleCard"; 10 | 11 | export default function ViewBattles({ battles, userId } : { battles: Battle[], userId: string }) { 12 | const [isModalOpen, setIsModalOpen] = useState(false); 13 | 14 | return ( 15 | <> 16 | 17 | 18 | Your past Battles} 29 | 30 | body={ 31 | <> 32 | {battles.filter((b) => b.completed).map((battle) => ( 33 | 34 | ))} 35 | 36 | } 37 | 38 | footer={ 39 | 40 | } 41 | /> 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /components/user/XPChart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" 4 | 5 | import { ChartConfig, ChartContainer } from "@/components/ui/chart" 6 | 7 | import { User_XP } from "@/types/db"; 8 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; 9 | 10 | export default function XPChart({ xp } : { xp: User_XP[] }) { 11 | 12 | const chartConfig = { 13 | 14 | } satisfies ChartConfig 15 | 16 | return ( 17 | <> 18 | 19 | 20 | Your XP history 21 | XP 22 | 23 | 24 | 25 | 26 | 27 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } -------------------------------------------------------------------------------- /components/course/CoursesShowcaseSwiper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | 4 | import { SwiperSlide, Swiper } from "swiper/react"; 5 | import { Scrollbar } from "swiper/modules"; 6 | import "swiper/css"; 7 | import "swiper/css/navigation"; 8 | import 'swiper/css/pagination'; 9 | import 'swiper/css/scrollbar'; 10 | 11 | import CourseCard from "./CourseCard"; 12 | 13 | import { Course } from "@/types/db"; 14 | import { SessionState } from "@/types/auth"; 15 | 16 | export default function CoursesShowcaseSwiper({ session, courses, label } : { session: SessionState, courses: Course[], label: string }) { 17 | 18 | return ( 19 |
20 |

{label}

21 | 30 | {courses.slice(0,5).map((course) => ( 31 | 32 | 36 | 37 | ))} 38 | 39 |
40 | ) 41 | } -------------------------------------------------------------------------------- /utils/supabase/reports.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | import { cache } from "react"; 6 | 7 | import { createClient } from "./server/server"; 8 | import { Report_Question } from "@/types/db"; 9 | 10 | export const getReportQuestionById = cache(async(id: string): Promise => { 11 | const { data, error } = await createClient() 12 | .from("reports") 13 | .select() 14 | .eq("id", id) 15 | .single(); 16 | if(error) { throw error; } 17 | return data; 18 | }) 19 | 20 | 21 | // no cache 22 | 23 | type ReportQuestionParams = { 24 | question: string; 25 | user: string; 26 | type?: string; 27 | } 28 | 29 | export async function reportQuestion(params: ReportQuestionParams): Promise { 30 | const { data, error } = await createClient() 31 | .from("reports_questions") 32 | .insert(params) 33 | .single(); 34 | 35 | if(error) { throw error; } 36 | return data; 37 | } 38 | 39 | 40 | type ResolveReportQuestionParams = { 41 | id: string; 42 | resolved: boolean; 43 | resolve_note?: string; 44 | } 45 | 46 | export async function resolveReportQuestion(params: ResolveReportQuestionParams): Promise { 47 | const { data, error } = await createClient() 48 | .from("reports_questions") 49 | .update({ resolved: params.resolved, resolve_note: params.resolve_note }) 50 | .eq("id", params.id) 51 | .single(); 52 | 53 | if(error) { throw error; } 54 | return data; 55 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import {nextui} from '@nextui-org/theme'; 2 | import type { Config } from "tailwindcss"; 3 | import typography from "@tailwindcss/typography"; 4 | import animate from "tailwindcss-animate" 5 | 6 | const config: Config = { 7 | content: [ 8 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 10 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 11 | "./node_modules/@nextui-org/theme/dist/components/(accordion|autocomplete|avatar|badge|button|calendar|card|checkbox|chip|divider|dropdown|image|input|kbd|link|modal|navbar|progress|radio|scroll-shadow|select|skeleton|slider|spinner|toggle|table|tabs|popover|user|ripple|listbox|menu|spacer).js" 12 | ], 13 | darkMode: "class", 14 | theme: { 15 | extend: { 16 | colors: { 17 | background: 'var(--background)', 18 | foreground: 'var(--foreground)' 19 | }, 20 | borderRadius: { 21 | lg: 'var(--radius)', 22 | md: 'calc(var(--radius) - 2px)', 23 | sm: 'calc(var(--radius) - 4px)' 24 | } 25 | } 26 | }, 27 | plugins: [ 28 | typography, 29 | nextui({ 30 | themes: { 31 | dark: { 32 | colors: { 33 | primary: { 34 | foreground: "#250326", 35 | DEFAULT: "#E879F9", 36 | } 37 | } 38 | }, 39 | light: { 40 | colors: { 41 | primary: { 42 | foreground: "#250326", 43 | DEFAULT: "#E879F9", 44 | } 45 | } 46 | }, 47 | } 48 | }), 49 | animate 50 | ], 51 | }; 52 | export default config; 53 | -------------------------------------------------------------------------------- /components/course/CourseAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import {Autocomplete, AutocompleteItem} from "@nextui-org/autocomplete"; 4 | import { useAsyncList } from "@react-stately/data"; 5 | 6 | import { Course } from "@/types/db"; 7 | import { searchCourses } from "@/utils/supabase/courses"; 8 | 9 | 10 | export default function CourseAutocomplete({ setCourse } : { setCourse: (course: Course) => void }) { 11 | 12 | const list = useAsyncList({ 13 | async load({filterText}) { 14 | const res = await searchCourses(filterText || ""); 15 | 16 | return { 17 | items: res, 18 | } 19 | } 20 | }) 21 | 22 | return ( 23 | { 36 | if(!key) return; 37 | setCourse(list.items.find((item) => item.id === key) as Course); 38 | }} 39 | 40 | > 41 | {(item) => ( 42 | 43 | {item.abbreviation} 44 | 45 | )} 46 | 47 | ); 48 | } -------------------------------------------------------------------------------- /utils/supabase/settings.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cache } from "react"; 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | 6 | import { createClient as getClient } from "./server/server"; 7 | 8 | import { Course, Settings } from "@/types/db"; 9 | 10 | export const getSettings = cache(async(userID: string): Promise => { 11 | const { data, error } = await getClient().from("settings").select(` 12 | *, 13 | courses (*) 14 | `).eq("user", userID); 15 | if(error) { throw error; } 16 | 17 | // oh god 18 | const tmp = data[0] as any; 19 | 20 | return { 21 | ...tmp, 22 | current_course: tmp?.courses as Course 23 | }; 24 | }) 25 | 26 | interface UpsertSettingsParams extends Partial { 27 | userId?: string; 28 | currentCourseId?: string; 29 | } 30 | 31 | export async function upsertSettings(settings: UpsertSettingsParams): Promise<{ id: string }> { 32 | if(!settings.user?.id && !settings.userId) { 33 | throw new Error("No user ID provided"); 34 | } 35 | 36 | const userId = settings.user?.id || settings.userId; 37 | const currentCourseId = settings.current_course?.id || settings.currentCourseId; 38 | 39 | delete (settings as any).courses; 40 | delete (settings as any).currentCourseId; 41 | delete (settings as any).userId; 42 | 43 | const { data, error } = await getClient().from("settings").upsert([{ 44 | ...settings, 45 | current_course: currentCourseId, 46 | user: userId 47 | }]).select().single(); 48 | if(error) { throw error; } 49 | return { id: data.id }; 50 | } 51 | 52 | -------------------------------------------------------------------------------- /app/level/new/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import Link from "next/link"; 4 | import { Button } from "@/components/utils/Button"; 5 | 6 | import Icon from "@/components/ui/Icon"; 7 | 8 | import LevelNewMain from "@/components/level/new/LevelNewMain"; 9 | import { getCourseById } from "@/utils/supabase/courses"; 10 | import { Course } from "@/types/db"; 11 | import { redirect } from "next/navigation"; 12 | 13 | type Props = { 14 | searchParams: { [key: string]: string } 15 | } 16 | 17 | export default async function NewLevel(props: Props) { 18 | const urlSearchParams = new URLSearchParams(props.searchParams); 19 | const courseId = urlSearchParams.get("courseId"); 20 | 21 | if(!courseId) { 22 | redirect("/"); 23 | } 24 | 25 | const course: Course = await getCourseById(courseId); 26 | 27 | return ( 28 | <> 29 |
30 |
31 |
32 | 33 | 39 | 40 |
41 |

Add a new Level to {course.abbreviation}

42 |
43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | ) 51 | } -------------------------------------------------------------------------------- /components/course/EditCourseCategory.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import CourseCategoryAutocomplete from "./CourseCategoryAutocomplete"; 6 | import { Course_Category } from "@/types/db"; 7 | import { Input } from "@nextui-org/input"; 8 | import { Button } from "../utils/Button"; 9 | import Icon from "../ui/Icon"; 10 | 11 | type Props = { 12 | category?: Course_Category, 13 | setCategory: (category: Course_Category) => void, 14 | } 15 | 16 | export default function EditCourseCategory(props: Props) { 17 | const [category, setCategory] = useState(props.category); 18 | 19 | useEffect(() => { 20 | if(props.category?.id !== category?.id) 21 | setCategory(props.category); 22 | }, [category, setCategory, props.category]) 23 | 24 | return ( 25 | <> 26 | { category?.title && category.title.length > 0 ? 27 |
28 | 29 | 39 |
40 | : 41 | 42 | } 43 | 44 | 45 | ) 46 | } -------------------------------------------------------------------------------- /components/user/Settings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import BlurModal from "../ui/BlurModal"; 6 | import EditSettingsCard from "./EditSettingsCard"; 7 | import { Button } from "../utils/Button"; 8 | import Icon from "@/components/ui/Icon"; 9 | import { SessionState } from "@/types/auth"; 10 | import EditProfileCard from "./EditProfileCard"; 11 | import LoginButton from "./LoginButton"; 12 | import DeleteAccountButton from "../auth/DeleteAccountButton"; 13 | 14 | 15 | export default function Settings({ sessionState } : { sessionState: SessionState }) { 16 | const [isModalOpen, setIsModalOpen] = useState(false); 17 | 18 | return ( 19 | <> 20 | 21 | Settings 33 | } 34 | body={ 35 | <> 36 | 37 | 38 | 39 | } 40 | footer={ 41 | <> 42 | 43 | 44 | 45 | } 46 | /> 47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /components/course/CourseCategoryAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from "react"; 3 | import {Autocomplete, AutocompleteItem} from "@nextui-org/autocomplete"; 4 | import { useAsyncList } from "@react-stately/data"; 5 | 6 | import { Course, Course_Category } from "@/types/db"; 7 | import { searchCourseCategories } from "@/utils/supabase/courses"; 8 | 9 | type Props = { 10 | setCategory: (category: Course_Category) => void, 11 | } 12 | 13 | export default function CourseCategoryAutocomplete(props: Props) { 14 | 15 | const list = useAsyncList({ 16 | async load({filterText}) { 17 | const res = await searchCourseCategories(filterText || ""); 18 | 19 | return { 20 | items: res, 21 | } 22 | } 23 | }) 24 | 25 | return ( 26 | { 38 | if(!key) return; 39 | props.setCategory(list.items.find((item) => item.id === key) as Course_Category); 40 | }} 41 | 42 | > 43 | {(item) => ( 44 | 45 | {item.title} 46 | 47 | )} 48 | 49 | ); 50 | } -------------------------------------------------------------------------------- /components/level/question/Option.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card, CardBody } from "@nextui-org/card" 4 | import Icon from "@/components/ui/Icon"; 5 | import React from "react"; 6 | 7 | import { OptionState } from "@/types/client"; 8 | 9 | export default function Option( 10 | { children, state, setQuestionState, active, size="md", isDisabled } : 11 | { 12 | children: React.ReactNode, 13 | state: OptionState 14 | setQuestionState: (state: boolean) => void, 15 | active: boolean, 16 | size?: "md" | "lg", 17 | isDisabled?: boolean 18 | }) { 19 | 20 | return ( 21 | active && setQuestionState(!(state == "selected"))} 26 | className={` 27 | flex flex-row items-center justify-start gap-2 border dark:border-primary/15 text-gray-500 dark:text-gray-300 28 | ${state == "selected" ? "bg-fuchsia-500 text-white" : ""} 29 | ${state == "correct" ? "bg-green-500 text-white" : ""} 30 | ${state == "wrong" ? "bg-red-500 text-white" : ""} 31 | ${state == "unselected" ? "bg-gray-100 dark:bg-primary/10" : ""} 32 | ${size == "lg" && "py-6"} 33 | `} 34 | 35 | > 36 | 37 | check_circle 38 | {children} 39 | 40 | 41 | ) 42 | 43 | } -------------------------------------------------------------------------------- /utils/supabase/server/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { cookies } from 'next/headers' 3 | 4 | export function createClient() { 5 | const cookieStore = cookies() 6 | 7 | return createServerClient( 8 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 9 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 10 | { 11 | cookies: { 12 | getAll() { 13 | return cookieStore.getAll() 14 | }, 15 | setAll(cookiesToSet) { 16 | try { 17 | cookiesToSet.forEach(({ name, value, options }) => 18 | cookieStore.set(name, value, options) 19 | ) 20 | } catch { 21 | // The `setAll` method was called from a Server Component. 22 | // This can be ignored if you have middleware refreshing 23 | // user sessions. 24 | } 25 | }, 26 | }, 27 | } 28 | ) 29 | } 30 | 31 | export function createAdminClient() { 32 | const cookieStore = cookies() 33 | 34 | return createServerClient( 35 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 36 | process.env.SUPABASE_SERVICE_ROLE_KEY!, 37 | { 38 | cookies: { 39 | getAll() { 40 | return cookieStore.getAll() 41 | }, 42 | setAll(cookiesToSet) { 43 | try { 44 | cookiesToSet.forEach(({ name, value, options }) => 45 | cookieStore.set(name, value, options) 46 | ) 47 | } catch { 48 | // The `setAll` method was called from a Server Component. 49 | // This can be ignored if you have middleware refreshing 50 | // user sessions. 51 | } 52 | }, 53 | }, 54 | } 55 | ) 56 | } -------------------------------------------------------------------------------- /app/level/edit/[level]/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import Link from "next/link"; 5 | 6 | import { Question, Topic } from "@/types/db"; 7 | 8 | import { Button } from "@/components/utils/Button"; 9 | import Icon from "@/components/ui/Icon"; 10 | import LevelEditMain from "@/components/level/edit/LevelEditMain"; 11 | 12 | import { getQuestions } from "@/utils/supabase/questions"; 13 | import { getTopic } from "@/utils/supabase/topics"; 14 | 15 | 16 | export default async function EditLevel({ params: { level } }: { params: { level: string } }) { 17 | 18 | let topic: Topic | null = null; 19 | 20 | try { 21 | topic = await getTopic(level); 22 | } catch (error) { 23 | console.error(error); 24 | redirect("/404"); 25 | } 26 | 27 | let questions: Question[] = []; 28 | 29 | try { 30 | questions = await getQuestions(topic.id); 31 | } catch (error) { 32 | console.error(error); 33 | } 34 | 35 | return ( 36 | <> 37 |
38 |
39 | 40 | 43 | 44 |

{topic.title}

45 |
46 | 47 | 48 | 52 | 53 |
54 | 55 | 56 | ) 57 | } -------------------------------------------------------------------------------- /components/levelScroller/CourseSectionBanner.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Popover, PopoverTrigger, PopoverContent } from "@nextui-org/popover"; 3 | import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; 4 | 5 | import { Course_Section } from "@/types/db"; 6 | import { Separator } from "../ui/separator"; 7 | 8 | export default function CourseSectionBanner( 9 | { courseSection } : 10 | { courseSection: Course_Section | null }) { 11 | 12 | return ( 13 | courseSection && 14 | <> 15 | 19 | 20 |
21 | 22 | 27 | {courseSection.title} 28 | 29 | 30 |
31 |
32 | 33 | 34 | 35 | {courseSection.title} 36 | {courseSection.description} 37 | 38 | 39 | 40 |
41 | 42 | ); 43 | } -------------------------------------------------------------------------------- /app/complete/[type]/[id]/stats/page.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { redirect } from "next/navigation"; 3 | 4 | import LevelCompleteStats from "@/components/level/complete/LevelCompleteStats"; 5 | import { getUserTopic } from "@/utils/supabase/topics"; 6 | import { getUser } from "@/utils/supabase/auth"; 7 | import { getTrainingById } from "@/utils/supabase/trainings"; 8 | 9 | import LevelCompleteContinueButton from "@/components/level/complete/LevelCompleteContinueButton"; 10 | 11 | type Params = { 12 | params : { 13 | type : "level" | "training"; 14 | id : string; 15 | }, 16 | searchParams: { [key: string]: string } 17 | } 18 | 19 | export default async function CompleteStats(params: Params) { 20 | const { type, id } = params.params; 21 | const urlSearchParams = new URLSearchParams(params.searchParams); 22 | const rankUp = urlSearchParams.get("rankUp") === "true"; 23 | 24 | const { data: { user } } = await getUser(); 25 | 26 | if(!user?.id) { 27 | redirect("/auth"); 28 | } 29 | 30 | let xp = 0, accuracy = 0, seconds = 0; 31 | 32 | if(type == "level") { 33 | const userTopic = await getUserTopic(user.id, id); 34 | xp = userTopic.xp; 35 | accuracy = userTopic.accuracy; 36 | seconds = userTopic.seconds; 37 | } else if (type == "training") { 38 | const { training } = await getTrainingById(id); 39 | xp = training.xp ?? 0; 40 | accuracy = training.accuracy ?? 0; 41 | seconds = training.seconds ?? 0; 42 | } 43 | 44 | return ( 45 | <> 46 | 47 | 48 | 49 | 50 | ) 51 | } -------------------------------------------------------------------------------- /components/training/FriendlistAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useEffect } from "react"; 3 | import {Autocomplete, AutocompleteItem} from "@nextui-org/autocomplete"; 4 | import { useAsyncList } from "@react-stately/data"; 5 | 6 | import { Followed_Profile, Profile } from "@/types/db"; 7 | import { searchFriends } from "@/utils/supabase/user"; 8 | 9 | 10 | export default function FriendlistAutocomplete({ setFriend, userId } : { userId: string, setFriend: (friend: Followed_Profile) => void }) { 11 | 12 | const list = useAsyncList({ 13 | async load({filterText}) { 14 | const res = await searchFriends(filterText || ""); 15 | 16 | return { 17 | items: res, 18 | } 19 | 20 | } 21 | }) 22 | 23 | useEffect(() => { 24 | if(userId) { 25 | list.reload(); 26 | } 27 | }, [userId]) 28 | 29 | 30 | return ( 31 | { 43 | if(!key) return; 44 | setFriend(list.items.find((item) => item.id === key) as Profile); 45 | }} 46 | 47 | > 48 | {(item) => ( 49 | 50 | {item.username} 51 | 52 | )} 53 | 54 | ); 55 | } -------------------------------------------------------------------------------- /components/ui/Header.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | import Streak from "./Streak"; 6 | import Xp from "./Xp"; 7 | import HeaderCourseSelect from "../header/HeaderCourseSelect"; 8 | 9 | import { Streak as StreakType } from "@/types/db"; 10 | 11 | import { getDayBefore, isSameDay } from "@/utils/functions/helpers"; 12 | import { getCurrentUser } from "@/utils/supabase/auth"; 13 | 14 | /** 15 | * Checks wether the streak is hanging (ends yesterday, aka user can extend it today) 16 | * @param streak Streak 17 | * @returns boolean 18 | */ 19 | const checkStreakHanging = (streak: StreakType): boolean => { 20 | const today = new Date(); 21 | const yesterday = getDayBefore(today); 22 | const streakEndDate = new Date(streak.to); 23 | 24 | if(isSameDay(yesterday, streakEndDate)) 25 | return true; 26 | return false; 27 | } 28 | 29 | 30 | export default async function Header() { 31 | 32 | const sessionState = await getCurrentUser(); 33 | 34 | if(!sessionState) { redirect("/auth") } 35 | 36 | return ( 37 | <> 38 |
39 |
40 | 41 | 46 | 47 |
48 |
49 | 50 | 51 | ) 52 | } -------------------------------------------------------------------------------- /app/training/[trainingId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | import { getCurrentUser } from "@/utils/supabase/auth"; 6 | import { getTrainingById } from "@/utils/supabase/trainings"; 7 | 8 | import LevelMain from "@/components/level/LevelMain"; 9 | import { shuffleArray } from "@/utils/functions/helpers"; 10 | import { LevelState } from "@/types/client"; 11 | 12 | 13 | export default async function TrainingLevel({ params: { trainingId } } : { params: { trainingId: string } }) { 14 | 15 | 16 | const session = await getCurrentUser(); 17 | 18 | if(!session) { 19 | redirect("/auth"); 20 | } 21 | 22 | const { training, questions } = await getTrainingById(trainingId); 23 | 24 | if(!questions) { 25 | redirect("/404"); 26 | } 27 | 28 | const randomizedQuestions = shuffleArray(questions); 29 | 30 | const initLevelState: LevelState = { 31 | progress: 0, 32 | answeredQuestions: 0, 33 | correctQuestions: 0, 34 | totalQuestions: randomizedQuestions.length, 35 | xp: 0, 36 | currentQuestionIndex: 0, 37 | seconds: 0, 38 | rankUp: false, 39 | questions: randomizedQuestions.map(question => ({ id: question.id, completed: false })), 40 | } 41 | 42 | return ( 43 |
44 | 45 |
46 | 47 | 54 | 55 |
56 |
57 | ) 58 | } -------------------------------------------------------------------------------- /utils/indexedDB/topics.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cache } from "react"; 4 | 5 | import { openDB, upgradeDB } from "./indexedDB"; 6 | import { Topic } from "@/types/db"; 7 | 8 | 9 | export const saveTopicsToLocal = async (topics: Topic[]): Promise => { 10 | const db = await openDB(); 11 | try { 12 | const transaction = db.transaction("topics", "readwrite") 13 | const store = transaction.objectStore("topics"); 14 | 15 | topics.forEach((topic) => { 16 | // use put since we might be updating the topic 17 | const request = store.put(topic); 18 | 19 | request.onerror = function(event) { 20 | console.error(event); 21 | } 22 | }); 23 | 24 | return topics; 25 | } catch (error) { 26 | const err = error as Error; 27 | if(err.name === "NotFoundError") { 28 | await upgradeDB(); 29 | 30 | // repeat the process 31 | const res = saveTopicsToLocal(topics); 32 | return res; 33 | } 34 | return []; 35 | } 36 | } 37 | 38 | export const getTopicsLocal = cache(async (courseId: string): Promise => { 39 | return new Promise(async (resolve, reject) => { 40 | const db = await openDB(); 41 | const transaction = db.transaction("topics", "readonly"); 42 | const store = transaction.objectStore("topics"); 43 | 44 | const request = store.getAll(); 45 | 46 | request.onsuccess = function(this) { 47 | const topics = this.result as Topic[]; 48 | 49 | // filter by courseId 50 | resolve(topics.filter((t) => t.course.id === courseId)); 51 | } 52 | 53 | request.onerror = function(event) { 54 | console.error(event); 55 | reject([]); 56 | } 57 | }); 58 | }) -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | 5 | import LevelScroller from "@/components/levelScroller/LevelScroller"; 6 | import Header from "@/components/ui/Header"; 7 | import Navigation from "@/components/ui/Navigation"; 8 | 9 | import { CurrentCourseProvider } from "@/context/SharedCourse"; 10 | 11 | import { getCurrentUser } from "@/utils/supabase/auth"; 12 | import { getUserCourse } from "@/utils/supabase/courses"; 13 | import { getCourseTopics } from "@/utils/supabase/topics"; 14 | import TeacherButton from "@/components/teacher/TeacherButton"; 15 | 16 | 17 | export default async function Home() { 18 | 19 | const sessionState = await getCurrentUser(); 20 | 21 | if(!sessionState?.user?.id || !sessionState.profile) { 22 | redirect("/auth") 23 | } 24 | 25 | const currentCourse = sessionState.settings.current_course; 26 | 27 | if(!currentCourse) { 28 | // redirect to course selection 29 | redirect("/auth/course"); 30 | } 31 | 32 | const currentUserCourse = await getUserCourse(currentCourse.id, sessionState.user.id); 33 | const initTopics = await getCourseTopics(currentCourse.id, 0, 5); 34 | 35 | return ( 36 | <> 37 | 38 |
39 |
40 |
41 | 45 |
46 | 47 |
48 | 49 |
50 |
51 |
52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/course/edit/[course]/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import Link from "next/link"; 5 | 6 | import { Course_Section, User_Course } from "@/types/db"; 7 | 8 | import { Button } from "@/components/utils/Button"; 9 | import EditCourseCard from "@/components/course/EditCourseCard"; 10 | import Icon from "@/components/ui/Icon"; 11 | import { getCurrentUser } from "@/utils/supabase/auth"; 12 | import { getUserCourse } from "@/utils/supabase/courses"; 13 | import { getCourseSections } from "@/utils/supabase/courseSections"; 14 | import CourseEditmain from "@/components/course/edit/CourseEditMain"; 15 | 16 | 17 | export default async function EditCourse({ params: { course }} : { params: { course: string }}) { 18 | 19 | const session = await getCurrentUser(); 20 | 21 | if(!session?.user?.id) { 22 | redirect("/auth"); 23 | } 24 | 25 | let userCourse: User_Course | null = null; 26 | 27 | try { 28 | userCourse = await getUserCourse(course, session.user.id); 29 | } catch (error) { 30 | console.error(error); 31 | redirect("/404"); 32 | } 33 | 34 | userCourse.user = session.user; 35 | 36 | let courseSections: Course_Section[] = []; 37 | 38 | try { 39 | courseSections = await getCourseSections(course); 40 | } catch (error) { 41 | console.error(error); 42 | } 43 | 44 | return ( 45 | <> 46 |
47 | 48 |
49 | 50 | 51 | 52 | 53 |
54 | 55 | 59 | 60 | 61 | 62 |
63 | 64 | 65 | ); 66 | } -------------------------------------------------------------------------------- /app/level/[level]/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getCurrentUser } from "@/utils/supabase/auth"; 4 | import { redirect } from "next/navigation"; 5 | import { getTopic } from "@/utils/supabase/topics"; 6 | 7 | import LevelMain from "@/components/level/LevelMain"; 8 | import { getQuestions } from "@/utils/supabase/questions"; 9 | import { shuffleArray } from "@/utils/functions/helpers"; 10 | import { LevelState } from "@/types/client"; 11 | 12 | export default async function Level({ params } : { params: { level: string }}) { 13 | 14 | const session = await getCurrentUser(); 15 | 16 | if(!session) { 17 | redirect("/auth"); 18 | } 19 | 20 | const topic = await getTopic(params.level); 21 | 22 | if(!topic) { 23 | redirect("/404"); 24 | } 25 | 26 | const questions = await getQuestions(topic.id); 27 | 28 | if(!questions) { 29 | redirect("/404"); 30 | } 31 | 32 | const randomizedQuestions = shuffleArray(questions); 33 | 34 | const initLevelState: LevelState = { 35 | progress: 0, 36 | answeredQuestions: 0, 37 | correctQuestions: 0, 38 | totalQuestions: randomizedQuestions.length, 39 | xp: 0, 40 | currentQuestionIndex: 0, 41 | seconds: 0, 42 | rankUp: false, 43 | questions: randomizedQuestions.map(question => ({ id: question.id, completed: false })), 44 | } 45 | 46 | return ( 47 |
48 | 49 |
50 | 51 | 57 | 58 |
59 |
60 | ) 61 | } -------------------------------------------------------------------------------- /components/training/TrainingCompleteMain.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SessionState } from "@/types/auth"; 4 | import { Training } from "@/types/db"; 5 | 6 | import { Button } from "../utils/Button"; 7 | import TrainingComplete from "./TrainingComplete"; 8 | import { useState } from "react"; 9 | import LevelCompleteStreak from "../level/complete/Streak"; 10 | import LevelCompleteRank from "../level/complete/LevelCompleteRank"; 11 | import ConditionalLink from "../ui/ConditionalLink"; 12 | import Icon from "@/components/ui/Icon"; 13 | 14 | export default function TrainingCompleteMain({ training, sessionState, rankUp=false } : { training: Training, sessionState: SessionState, rankUp?: boolean }) { 15 | const [step, setStep] = useState(0); 16 | 17 | return ( 18 | <> 19 | 20 | { step == 0 && 21 | <> 22 |

Training complete!

23 | 24 | 25 | } 26 | 27 | { step == 1 && sessionState.currentStreakDays && 28 | <> 29 | 30 | 31 | } 32 | 33 | { step == 2 && sessionState.profile?.rank.title && rankUp && 34 | 35 | } 36 | 37 | = 2 : step >= 1} href="/" > 38 | 50 | 51 | 52 | ) 53 | } -------------------------------------------------------------------------------- /app/training/[trainingId]/complete/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import TrainingCompleteMain from "@/components/training/TrainingCompleteMain"; 4 | import { getCurrentUser } from "@/utils/supabase/auth"; 5 | import { getTrainingById } from "@/utils/supabase/trainings"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export default async function TrainingCompleteScreen({ params: { trainingId }, searchParams } : { params: { trainingId: string }, searchParams: { [key: string]: string } }) { 9 | const urlSearchParams = new URLSearchParams(searchParams); 10 | const rankUp = urlSearchParams.get("rankUp") === "true"; 11 | 12 | const { training } = await getTrainingById(trainingId); 13 | const currentUser = await getCurrentUser(); 14 | 15 | if(!currentUser?.user?.id) { 16 | redirect("/auth"); 17 | } 18 | 19 | return ( 20 |
21 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ) 33 | } -------------------------------------------------------------------------------- /components/level/question/questionTypes/EditBoolean.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { Switch } from "@nextui-org/switch"; 3 | 4 | import { Question } from "@/types/db"; 5 | import { cn } from "@/utils/utils"; 6 | 7 | 8 | export default function EditBoolean( 9 | { question, handleUpdateValue } 10 | : 11 | { question: Question, handleUpdateValue: (key: keyof Question, value: string | string[]) => void } 12 | ) { 13 | 14 | return ( 15 | <> 16 |
17 | handleUpdateValue("answers_correct", isTrue ? ["true"] : ["false"])} 20 | classNames={{ 21 | base: cn( 22 | "inline-flex flex-row-reverse w-full max-w-md bg-content1 hover:bg-content2 items-center", 23 | "justify-between cursor-pointer rounded-lg gap-2 p-4 border-2 border-transparent", 24 | "data-[selected=true]:border-primary", 25 | ), 26 | wrapper: "p-0 h-4 overflow-visible", 27 | thumb: cn("w-6 h-6 border-2 shadow-lg", 28 | "group-data-[hover=true]:border-primary", 29 | //selected 30 | "group-data-[selected=true]:ml-6", 31 | // pressed 32 | "group-data-[pressed=true]:w-7", 33 | "group-data-[selected]:group-data-[pressed]:ml-4", 34 | ), 35 | }} 36 | > 37 |
38 |

Is it true?

39 |

40 | Select wether the Question Statement is True or False. 41 |

42 |
43 |
44 | 45 |
46 | 47 | ) 48 | } -------------------------------------------------------------------------------- /components/teacher/Message.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Markdown from "react-markdown"; 4 | import remarkGfm from 'remark-gfm'; 5 | import { motion } from "framer-motion"; 6 | 7 | import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "../ui/card"; 8 | 9 | type MessageProps = { 10 | message: string; 11 | role: "function" | "data" | "system" | "user" | "assistant" | "tool" 12 | } 13 | 14 | export default function Message(props: MessageProps) { 15 | 16 | 17 | return ( 18 | 28 | 29 | {props.role !== "user" && 30 | 31 | {props.role == "assistant" && "Teacher"} 32 | 33 | } 34 | 35 | 39 | {props.message} 40 | 41 | 42 | { props.role === "assistant" && 43 | 44 |

Messages by the Teacher can include incorrect information

45 |
46 | } 47 |
48 |
49 | ) 50 | } -------------------------------------------------------------------------------- /components/utils/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Button as NextUIButton } from "@nextui-org/button" 2 | import React from "react" 3 | 4 | const Button = React.forwardRef(({ 5 | children, 6 | className, 7 | onClick, 8 | type, 9 | variant, 10 | size, 11 | isLoading, 12 | isDisabled, 13 | color, 14 | startContent, 15 | endContent, 16 | isIconOnly, 17 | fullWidth, 18 | radius, 19 | formAction, 20 | form 21 | 22 | } : { 23 | children: React.ReactNode, 24 | className? : string | undefined, 25 | onClick? : () => void, 26 | type?: "button" | "submit" | "reset", 27 | variant?: "light" | "solid" | "bordered" | "flat" | "faded" | "shadow" | "ghost" | undefined, 28 | size?: "sm" | "md" | "lg" | undefined, 29 | isLoading?: boolean, 30 | isDisabled?: boolean, 31 | color?: "default" | "primary" | "secondary" | "success" | "warning" | "danger", 32 | startContent?: React.ReactNode, 33 | endContent?: React.ReactNode, 34 | isIconOnly?: boolean, 35 | fullWidth?: boolean, 36 | radius?: "none" | "sm" | "md" | "lg" | "full" | undefined, 37 | form?: string, 38 | formAction?: string | ((formData: FormData) => void | Promise) | undefined 39 | }, ref: React.ForwardedRef) => { 40 | 41 | return ( 42 | 60 | {children} 61 | 62 | ) 63 | }) 64 | 65 | Button.displayName = "Button" 66 | export {Button}; -------------------------------------------------------------------------------- /components/community/CommunityMain.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import Link from "next/link"; 5 | import { Suspense } from "react"; 6 | 7 | import { UserCoursesProvider } from "@/context/SharedUserCourses"; 8 | import { Button } from "../utils/Button"; 9 | 10 | import Courses from "../course/Courses"; 11 | import CourseSearch from "../course/CourseSearch"; 12 | import Icon from "../ui/Icon"; 13 | 14 | import { getCurrentUser } from "@/utils/supabase/auth"; 15 | 16 | 17 | export default async function CommunityMain() { 18 | 19 | const session = await getCurrentUser(); 20 | 21 | if(!session) { 22 | redirect('/auth'); 23 | } 24 | 25 | 26 | return ( 27 | 28 |
29 |
30 |

Community

31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 | 39 | 40 |
41 | 42 | 43 | Loading Course Search......
}> 44 | 45 | 46 | 47 | Loading Courses......}> 48 | 49 | 50 | 51 |
52 | ) 53 | } -------------------------------------------------------------------------------- /utils/supabase/battles.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | 4 | import { cache } from "react"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import { createClient } from "./server/server"; 7 | import { Battle } from "@/types/db"; 8 | 9 | 10 | export const getBattles = cache(async (userID: string): Promise => { 11 | const { data, error } = await createClient() 12 | .from("battles") 13 | .select(` 14 | user_initiator (*), 15 | other_user (*), 16 | * 17 | `) 18 | .eq("user_initiator", userID) 19 | if (error) throw error 20 | 21 | return data as Battle[] 22 | }) 23 | 24 | // no cache 25 | 26 | export async function createBattle({ userInitId, userOtherId, xp_goal } : { userInitId: string; userOtherId: string; xp_goal: number }): Promise { 27 | const today = new Date() 28 | const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7) 29 | 30 | const id = uuidv4() 31 | 32 | const { data, error } = await createClient() 33 | .from("battles") 34 | .insert({ 35 | id: id, 36 | user_initiator: userInitId, 37 | other_user: userOtherId, 38 | xp_goal: xp_goal, 39 | end_date: nextWeek.toISOString() 40 | }) 41 | .eq("user_initiator", userInitId) 42 | .eq("other_user", userOtherId) 43 | .eq("id", id) 44 | .select("*") 45 | .single() 46 | 47 | if (error) throw error 48 | 49 | return data as Battle 50 | } 51 | 52 | export async function updateBattle(data: Partial): Promise { 53 | const { error } = await createClient() 54 | .from("battles") 55 | .update(data) 56 | .eq("id", data.id) 57 | if (error) throw error 58 | } 59 | 60 | export async function forfeitBattle(battleId: string, userId: string): Promise { 61 | const { error } = await createClient() 62 | .from("battles") 63 | .update({ completed: true, forfeit_user: userId }) 64 | .eq("id", battleId) 65 | if (error) throw error 66 | } -------------------------------------------------------------------------------- /utils/supabase/ranks.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | 5 | import { cache } from "react"; 6 | 7 | import { getSession } from "./auth"; 8 | import { createClient as getClient } from "./server/server"; 9 | import { Rank } from "@/types/db"; 10 | import { getProfileById } from "./user"; 11 | 12 | 13 | export const getRanks = cache(async(): Promise => { 14 | const { data, error } = await getClient() 15 | .from("ranks") 16 | .select() 17 | .order("xp_threshold", { ascending: true }); 18 | if(error) { throw error; } 19 | return data; 20 | }) 21 | 22 | export const getNextRank = cache(async(currentRank: Rank): Promise => { 23 | const { data, error } = await getClient() 24 | .from("ranks") 25 | .select() 26 | .gt("xp_threshold", currentRank.xp_threshold) 27 | .order("xp_threshold", { ascending: true }) 28 | .limit(1) 29 | .single(); 30 | if(error) { throw error; } 31 | return data as Rank; 32 | }) 33 | 34 | export const getCurrentUserRank = cache(async(): Promise => { 35 | const session = await getSession(); 36 | if(!session.data.session) { 37 | throw new Error("No session found"); 38 | } 39 | 40 | const profile = await getProfileById(session.data.session.user.id as string); 41 | return profile.rank as Rank; 42 | }) 43 | 44 | // no cache 45 | 46 | export async function tryRankUp(userID: string, xp: number, currentRank: Rank): Promise<{ rank: Rank, rankedUp: boolean }> { 47 | try { 48 | const nextRank = await getNextRank(currentRank); 49 | if(xp >= nextRank.xp_threshold) { 50 | // rank up 51 | const { error } = await getClient().from("profiles").update({ rank: nextRank.id }).eq("id", userID).select(); 52 | if(error) { throw error; } 53 | return { rank: nextRank, rankedUp: true }; 54 | } else { 55 | return { rank: currentRank, rankedUp: false }; 56 | } 57 | } catch (e) { 58 | console.error("Error trying to rank up:", e); 59 | return { rank: currentRank, rankedUp: false }; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /components/courseSection/CourseSectionAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useEffect } from "react"; 3 | import {Autocomplete, AutocompleteItem} from "@nextui-org/autocomplete"; 4 | import { useAsyncList } from "@react-stately/data"; 5 | 6 | import { Course, Course_Section } from "@/types/db"; 7 | import { searchCourseSections } from "@/utils/supabase/courses"; 8 | 9 | 10 | export default function CourseSectionAutocomplete({ setCourseSection, course, description } : { course: Course | null, setCourseSection: (courseSection: Course_Section) => void, description?: string }) { 11 | 12 | const list = useAsyncList({ 13 | async load({filterText}) { 14 | if(course) { 15 | const res = await searchCourseSections(filterText || "", course); 16 | 17 | return { 18 | items: res, 19 | } 20 | } 21 | return { 22 | items: [] as Course_Section[] 23 | } 24 | 25 | 26 | } 27 | }) 28 | 29 | useEffect(() => { 30 | if(course && course.id !== "") { 31 | list.reload(); 32 | } 33 | }, [course]) 34 | 35 | 36 | return ( 37 | { 49 | if(!key) return; 50 | setCourseSection(list.items.find((item) => item.id === key) as Course_Section); 51 | }} 52 | 53 | > 54 | {(item) => ( 55 | 56 | {item.title} 57 | 58 | )} 59 | 60 | ); 61 | } -------------------------------------------------------------------------------- /components/ui/Navigation.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import Link from "next/link" 4 | import React from "react" 5 | 6 | import Icon from "./Icon"; 7 | 8 | type Links = "Home" | "Training" | "Community" | "Profile" | "Leaderboard" 9 | 10 | const LinkComponent = ({ href, icon, activeTitle, title } : { href: string, icon: string, activeTitle: string | undefined, title: Links }) => { 11 | const active: boolean = (activeTitle && (activeTitle === title)) || false; 12 | 13 | return ( 14 | 18 | 19 | {icon} 20 | 21 | {title} 22 | 23 | ) 24 | } 25 | 26 | export default async function Navigation({ activeTitle } : { activeTitle: Links | undefined }) { 27 | 28 | return ( 29 | <> 30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | 38 |
39 |
40 |
41 | 42 | ) 43 | 44 | } -------------------------------------------------------------------------------- /app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/utils/Button"; 2 | import { 3 | Navbar, 4 | NavbarBrand, 5 | NavbarContent, 6 | NavbarItem 7 | } from "@nextui-org/navbar"; 8 | import Link from "next/link"; 9 | 10 | export default function Layout({children} : {children: React.ReactNode}) { 11 | 12 | return ( 13 | <> 14 | 15 |
16 | 17 | 20 | 21 | Nouv 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | {children} 32 | 33 |
34 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | 43 | ) 44 | } -------------------------------------------------------------------------------- /utils/supabase/weeklyGoals.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cache } from "react"; 4 | import { createClient } from "./server/server"; 5 | import { Weekly_Goal } from "@/types/db"; 6 | /* eslint-disable @typescript-eslint/no-explicit-any */ 7 | 8 | 9 | export const getWeeklyGoals = cache(async (): Promise => { 10 | const { data, error } = await createClient() 11 | .from('weekly_goals') 12 | .select('*') 13 | if (error) throw error 14 | return data as any 15 | }) 16 | 17 | export const getWeeklyGoalByUser = cache(async (userId: string): Promise => { 18 | const { data, error } = await createClient() 19 | .from('weekly_goals') 20 | .select('*') 21 | .eq('user', userId) 22 | if (error) throw error 23 | return data[0] as Weekly_Goal 24 | }) 25 | 26 | 27 | export const getTimeSpentByUser = cache(async (userId: string): Promise => { 28 | 29 | const currentWeekStart = new Date() 30 | const currentWeekEnd = new Date() 31 | 32 | currentWeekStart.setDate(currentWeekStart.getDate() - currentWeekStart.getDay()) 33 | currentWeekStart.setHours(0, 0, 0, 0) 34 | 35 | currentWeekEnd.setDate(currentWeekEnd.getDate() + (6 - currentWeekEnd.getDay())) 36 | currentWeekEnd.setHours(23, 59, 59, 999) 37 | 38 | const { data, error } = await createClient() 39 | .from('users_topics') 40 | .select('seconds') 41 | .eq('user', userId) 42 | .gte('created_at', currentWeekStart.toISOString()) 43 | .lte('created_at', currentWeekEnd.toISOString()) 44 | if (error) throw error 45 | return data.reduce((acc: number, curr: any) => acc + curr.seconds, 0) 46 | }) 47 | 48 | // no cache 49 | 50 | export async function setWeeklyGoalByUser({ id, userId, goal } : { id: string; userId: string; goal: number }): Promise { 51 | const { data, error } = await createClient() 52 | .from('weekly_goals') 53 | .upsert({ 54 | id: id, 55 | user: userId, 56 | goal: goal 57 | }) 58 | .eq('id', id) 59 | .select('*') 60 | .single() 61 | 62 | if(error){ 63 | throw error 64 | } 65 | 66 | return data as Weekly_Goal 67 | } -------------------------------------------------------------------------------- /components/ui/FollowButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | import { Button } from "../utils/Button"; 5 | 6 | import { followUser, getFriendStatus, unFollowUser } from "@/utils/supabase/user"; 7 | import Icon from "./Icon"; 8 | 9 | export default function FollowButton({ userId, otherUserId } : { userId: string, otherUserId: string }) { 10 | const [isFollow, setIsFollow] = useState(false); 11 | const [isLoading, setIsLoading] = useState(true); 12 | 13 | useEffect(() => { 14 | const fetchFollowStatus = async () => { 15 | setIsLoading(true); 16 | try { 17 | const res = await getFriendStatus({ userId, otherUserId }); 18 | if(res?.follows || res?.friends) { 19 | setIsFollow(true); 20 | } 21 | } catch (e) { 22 | console.error("Error fetching follow status", e); 23 | setIsFollow(false); 24 | } 25 | setIsLoading(false); 26 | } 27 | 28 | fetchFollowStatus(); 29 | }, [userId, otherUserId]) 30 | 31 | const handleFollowUser = async () => { 32 | if(isFollow) { 33 | try { 34 | // unfollow 35 | setIsFollow(false); 36 | await unFollowUser({ userId, otherUserId }); 37 | } catch (e) { 38 | console.error("Error unfollowing user", e); 39 | setIsFollow(true); 40 | } 41 | 42 | } else { 43 | try { 44 | // follow 45 | setIsFollow(true); 46 | await followUser({ userId, otherUserId }); 47 | } catch (e) { 48 | console.error("Error following user", e); 49 | setIsFollow(false); 50 | } 51 | 52 | } 53 | } 54 | 55 | return ( 56 | <> 57 | 66 | 67 | ) 68 | } -------------------------------------------------------------------------------- /components/training/ViewWeakQuestions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Weak_User_Questions } from "@/types/db"; 6 | 7 | 8 | import { Button } from "../utils/Button"; 9 | import Icon from "@/components/ui/Icon"; 10 | import BlurModal from "../ui/BlurModal"; 11 | import { Card, CardTitle, CardHeader, CardContent, CardDescription } from "../ui/card"; 12 | 13 | type Params = { 14 | weakQuestions: Weak_User_Questions[]; 15 | } 16 | 17 | export default function ViewWeakQuestions(params: Params) { 18 | const [isModalOpen, setIsModalOpen] = useState(false); 19 | 20 | 21 | return ( 22 | <> 23 | 32 | 33 | Weak Questions} 43 | body={ 44 | <> 45 |
46 | {params.weakQuestions.map((q, i) => ( 47 | 48 | 49 | {q.question.question} 50 | {q.question.title} 51 | 52 | 53 | {Math.round(q.score)} Score 54 | 55 | 56 | 57 | ))} 58 |
59 | 60 | } 61 | footer={ 62 | <> 63 | 64 | 65 | } 66 | /> 67 | 68 | ); 69 | } -------------------------------------------------------------------------------- /components/user/LeaderBoardCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card, CardBody } from "@nextui-org/card"; 4 | 5 | import { Profile } from "@/types/db"; 6 | 7 | import Username from "@/components/user/Username"; 8 | import { SessionState } from "@/types/auth"; 9 | import { useState } from "react"; 10 | 11 | export default function LeaderboardCard({ profile, sessionState, position } : { profile: Profile, sessionState: SessionState, position: number }) { 12 | const [isModalOpen, setIsModalOpen] = useState(false); 13 | 14 | return ( 15 | <> 16 | setIsModalOpen(true)} 17 | classNames={{ 18 | base: " light:bg-white dark:bg-transparent dark:bg-neutral-800/25 dark:border-gray-700 border-gray-200", 19 | }} 20 | > 21 | 22 |
23 | { 24 |
25 | 33 | {position} 34 | 35 |
36 | } 37 | 43 |
44 | {profile?.total_xp.toLocaleString()} XP 45 |
46 |
47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /components/auth/DeleteAccountButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { deleteAccount } from "@/utils/supabase/auth"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { useToast } from "@/hooks/use-toast"; 7 | import { Button } from "../utils/Button"; 8 | import Icon from "../ui/Icon"; 9 | 10 | import { 11 | AlertDialog, 12 | AlertDialogAction, 13 | AlertDialogCancel, 14 | AlertDialogContent, 15 | AlertDialogDescription, 16 | AlertDialogFooter, 17 | AlertDialogHeader, 18 | AlertDialogTitle, 19 | AlertDialogTrigger, 20 | } from "@/components/ui/alert-dialog" 21 | 22 | 23 | export default function DeleteAccountButton() { 24 | const { toast } = useToast(); 25 | 26 | const handleDeleteAccount = async () => { 27 | 28 | try { 29 | await deleteAccount(); 30 | 31 | redirect("/"); 32 | } catch(e) { 33 | console.error(e); 34 | toast({ 35 | title: "Error deleting account", 36 | description: "There was an error deleting your account. Please try again.", 37 | variant: "destructive" 38 | }) 39 | } 40 | 41 | } 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 48 | 51 | 52 | 53 | 54 | 55 | 56 | Delete Account 57 | 58 | This action cannot be undone. This will permanently delete your account 59 | and remove your data from our servers. 60 | 61 | 62 | 63 | 64 | Cancel 65 | Continue 66 | 67 | 68 | 69 | 70 | 71 | 72 | ) 73 | } -------------------------------------------------------------------------------- /utils/supabase/server/middleware.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from '@supabase/ssr' 2 | import { NextResponse, type NextRequest } from 'next/server' 3 | 4 | export async function updateSession(request: NextRequest) { 5 | let supabaseResponse = NextResponse.next({ 6 | request, 7 | }) 8 | 9 | const supabase = createServerClient( 10 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 12 | { 13 | cookies: { 14 | getAll() { 15 | return request.cookies.getAll() 16 | }, 17 | setAll(cookiesToSet) { 18 | cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) 19 | supabaseResponse = NextResponse.next({ 20 | request, 21 | }) 22 | cookiesToSet.forEach(({ name, value, options }) => 23 | supabaseResponse.cookies.set(name, value, options) 24 | ) 25 | }, 26 | }, 27 | } 28 | ) 29 | 30 | // IMPORTANT: Avoid writing any logic between createServerClient and 31 | // supabase.auth.getUser(). A simple mistake could make it very hard to debug 32 | // issues with users being randomly logged out. 33 | 34 | const { 35 | data: { user }, 36 | } = await supabase.auth.getUser() 37 | 38 | if ( 39 | !user && 40 | !request.nextUrl.pathname.startsWith('/auth') && 41 | !request.nextUrl.pathname.startsWith('/auth') 42 | ) { 43 | // no user, potentially respond by redirecting the user to the login page 44 | const url = request.nextUrl.clone() 45 | url.pathname = '/auth' 46 | return NextResponse.redirect(url) 47 | } 48 | 49 | // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're 50 | // creating a new response object with NextResponse.next() make sure to: 51 | // 1. Pass the request in it, like so: 52 | // const myNewResponse = NextResponse.next({ request }) 53 | // 2. Copy over the cookies, like so: 54 | // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) 55 | // 3. Change the myNewResponse object to fit your needs, but avoid changing 56 | // the cookies! 57 | // 4. Finally: 58 | // return myNewResponse 59 | // If this is not done, you may be causing the browser and server to go out 60 | // of sync and terminate the user's session prematurely! 61 | 62 | return supabaseResponse 63 | } -------------------------------------------------------------------------------- /components/leaderboard/LeaderboardScroller.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { SessionState } from "@/types/auth"; 5 | import { Profile, Rank } from "@/types/db"; 6 | import InfiniteScroll from "react-infinite-scroller"; 7 | import { Spinner } from "@nextui-org/spinner"; 8 | import LeaderboardCard from "../user/LeaderBoardCard"; 9 | import { getProfilesInRank } from "@/utils/supabase/user"; 10 | 11 | 12 | type Props = { 13 | sessionState: SessionState, 14 | ranks: Rank[], 15 | nextRank: Rank, 16 | initProfilesInRank: Profile[] 17 | } 18 | 19 | const _limit = 20; 20 | 21 | export default function LeaderboardScroller(props: Props) { 22 | 23 | const [profiles, setProfiles] = useState(props.initProfilesInRank); 24 | const [hasMore, setHasMore] = useState(true); 25 | const [cursor, setCursor] = useState(props.initProfilesInRank.length); 26 | const [isLoading, setIsLoading] = useState(false); 27 | 28 | const handleLoadMore = async () => { 29 | if(isLoading) return; 30 | 31 | setIsLoading(true); 32 | 33 | const result = await getProfilesInRank({ 34 | rankID: props.sessionState.profile?.rank?.id, 35 | offset: cursor, 36 | limit: 20 37 | }) 38 | 39 | if(result.length === 0 || result.length < _limit) { 40 | setHasMore(false); 41 | } 42 | 43 | setProfiles([...profiles, ...result]); 44 | setCursor(cursor + _limit); 45 | 46 | 47 | setIsLoading(false); 48 | } 49 | 50 | return ( 51 | <> 52 | handleLoadMore()} 57 | hasMore={hasMore} 58 | useWindow={false} 59 | threshold={50} 60 | loader={} 61 | key={"infinite-scroll"} 62 | > 63 | {profiles.map((profile, index) => ( 64 | 70 | ))} 71 | 72 | 73 | 74 | ) 75 | } -------------------------------------------------------------------------------- /components/user/FindFriendsButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { Button } from "../utils/Button"; 6 | import BlurModal from "../ui/BlurModal"; 7 | import { Input } from "@nextui-org/input"; 8 | import { Profile } from "@/types/db"; 9 | import { searchProfiles } from "@/utils/supabase/user"; 10 | import FollowButton from "../ui/FollowButton"; 11 | 12 | type Params = { 13 | userId: string; 14 | } 15 | 16 | export default function FindFriendsButton(params: Params) { 17 | const [isModalOpen, setIsModalOpen] = useState(false); 18 | const [search, setSearch] = useState(""); 19 | const [results, setResults] = useState([]); 20 | 21 | useEffect(() => { 22 | const handleSubmit = async () => { 23 | const res = await searchProfiles(search); 24 | setResults(res); 25 | } 26 | 27 | if(search.length > 0) { 28 | setTimeout(async () => { 29 | await handleSubmit(); 30 | }, 1000); 31 | } else if(search.length === 0) { 32 | setResults([]); 33 | } 34 | 35 | }, [search]) 36 | 37 | return ( 38 | <> 39 | 40 | 41 | Find friends} 51 | body={ 52 | <> 53 | 54 | {results.length > 0 && 55 | results.map((profile) => { 56 | return ( 57 |
58 | {profile.username} 59 | 60 |
61 | ) 62 | }) 63 | } 64 | 65 | } 66 | footer={} 67 | /> 68 | 69 | ) 70 | } -------------------------------------------------------------------------------- /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 "@/utils/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-neutral-950 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 dark:focus-visible:ring-neutral-300", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50/90", 14 | destructive: 15 | "bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-neutral-50 dark:hover:bg-red-900/90", 16 | outline: 17 | "border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 dark:border-neutral-800 dark:bg-neutral-950 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 18 | secondary: 19 | "bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-800/80", 20 | ghost: "hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-50", 21 | link: "text-neutral-900 underline-offset-4 hover:underline dark:text-neutral-50", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /utils/supabase/courseSections.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import { cache } from "react"; 5 | 6 | import { createClient as getClient } from "./server/server"; 7 | 8 | import { Course_Section } from "@/types/db"; 9 | 10 | export const getCourseSection = cache(async(courseSectionID: string): Promise => { 11 | const { data, error } = await getClient().from("course_sections").select(` 12 | id, 13 | created_at, 14 | title, 15 | description, 16 | order, 17 | courses ( 18 | id, 19 | title, 20 | abbreviation, 21 | description 22 | ) 23 | `).eq("id", courseSectionID).single(); 24 | if(error) { throw error; } 25 | return { 26 | id: data.id, 27 | created_at: data.created_at, 28 | title: data.title, 29 | description: data.description, 30 | order: data.order, 31 | course: data.courses as any 32 | } 33 | }) 34 | 35 | export const getCourseSections = cache(async(courseId: string): Promise => { 36 | const { data, error } = await getClient().from("course_sections").select(` 37 | id, 38 | created_at, 39 | title, 40 | description, 41 | order, 42 | courses ( 43 | id, 44 | title, 45 | abbreviation, 46 | description 47 | ) 48 | `).eq("course", courseId).order("order", { ascending: true }); 49 | if(error) { throw error; } 50 | return data.map((db: any) => { 51 | return { 52 | id: db.id, 53 | created_at: db.created_at, 54 | title: db.title, 55 | description: db.description, 56 | order: db.order, 57 | course: db.courses 58 | } 59 | }); 60 | }) 61 | 62 | 63 | // no caching 64 | 65 | export async function upsertCourseSection(courseSection: Course_Section): Promise<{ id: string }> { 66 | const { data, error } = await getClient().from("course_sections").upsert([{ 67 | ...courseSection, 68 | course: courseSection?.course?.id 69 | }]).select().single(); 70 | if(error) { throw error; } 71 | return { id: data.id }; 72 | } 73 | 74 | export async function deleteCourseSection(courseSectionID: string): Promise { 75 | const { error } = await getClient().from("course_sections").delete().eq("id", courseSectionID); 76 | if(error) { throw error; } 77 | return true; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react" 4 | import { Noise } from "react-noise"; 5 | 6 | import { cn } from "@/utils/utils" 7 | 8 | const Card = React.forwardRef< 9 | HTMLDivElement, 10 | React.HTMLAttributes 11 | >(({ className, children, ...props }, ref) => ( 12 |
20 | 21 | {children} 22 |
23 | )) 24 | Card.displayName = "Card" 25 | 26 | const CardHeader = React.forwardRef< 27 | HTMLDivElement, 28 | React.HTMLAttributes 29 | >(({ className, ...props }, ref) => ( 30 |
35 | )) 36 | CardHeader.displayName = "CardHeader" 37 | 38 | const CardTitle = React.forwardRef< 39 | HTMLParagraphElement, 40 | React.HTMLAttributes 41 | >(({ className, ...props }, ref) => ( 42 |

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

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

67 | )) 68 | CardContent.displayName = "CardContent" 69 | 70 | const CardFooter = React.forwardRef< 71 | HTMLDivElement, 72 | React.HTMLAttributes 73 | >(({ className, ...props }, ref) => ( 74 |
79 | )) 80 | CardFooter.displayName = "CardFooter" 81 | 82 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 83 | -------------------------------------------------------------------------------- /app/auth/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { redirect } from 'next/navigation' 4 | 5 | import { createClient } from '@/utils/supabase/server/server' 6 | 7 | type LoginParams = { 8 | email: string 9 | password: string 10 | } 11 | 12 | type LoginResponse = { 13 | hasAuthError: boolean 14 | authError: string 15 | } 16 | 17 | export async function login({ email, password }: LoginParams): Promise { 18 | const supabase = createClient() 19 | 20 | const { error } = await supabase.auth.signInWithPassword({ email, password }) 21 | 22 | if (error) { 23 | return { hasAuthError: true, authError: error.message } 24 | } 25 | 26 | redirect('/') 27 | } 28 | 29 | type SignUpParams = { 30 | username: string 31 | email: string 32 | password: string 33 | } 34 | 35 | type SignUpResponse = { 36 | hasAuthError: boolean 37 | authError: string 38 | hasProgresError: boolean 39 | postgresError: string 40 | } 41 | 42 | export async function signup({ username, email, password }: SignUpParams): Promise { 43 | const supabase = createClient() 44 | 45 | const { data: { user }, error } = await supabase.auth.signUp({ email, password }) 46 | 47 | if (error) { 48 | return { 49 | hasAuthError: true, 50 | authError: error.message, 51 | hasProgresError: false, 52 | postgresError: '' 53 | } 54 | } 55 | 56 | // get lowest rank id -> sort by xp_threshold 57 | const { data: ranks, error: gettingRankError } = await supabase.from("ranks").select("*").order("xp_threshold", { ascending: true }).limit(1); 58 | 59 | if(gettingRankError) { 60 | throw gettingRankError; 61 | } 62 | 63 | const dbResult = await supabase.from("profiles").insert([ 64 | { 65 | id: user?.id, 66 | username: username, 67 | avatar: null, // this can be null 68 | total_xp: 0, // no xp 69 | rank: ranks[0].id // lowest rank 70 | } 71 | ]).select().single(); 72 | 73 | if(dbResult.error) { 74 | return { 75 | hasAuthError: false, 76 | authError: '', 77 | hasProgresError: true, 78 | postgresError: dbResult.error.message 79 | }; 80 | } 81 | 82 | const settingsResult = await supabase.from("settings").insert([ 83 | { 84 | user: user?.id, 85 | theme: "light", 86 | color: "blue", 87 | } 88 | ]).select().single(); 89 | 90 | if(settingsResult.error) { 91 | return { 92 | hasAuthError: false, 93 | authError: '', 94 | hasProgresError: true, 95 | postgresError: settingsResult.error.message 96 | } 97 | } 98 | 99 | redirect('/auth/course') 100 | } -------------------------------------------------------------------------------- /app/leaderboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { redirect } from "next/navigation"; 4 | import { Progress } from "@nextui-org/progress"; 5 | 6 | import Navigation from "@/components/ui/Navigation"; 7 | 8 | import { getCurrentUser } from "@/utils/supabase/auth"; 9 | import { getProfilesInRank } from "@/utils/supabase/user"; 10 | import { getRanks } from "@/utils/supabase/ranks"; 11 | import LeaderboardScroller from "@/components/leaderboard/LeaderboardScroller"; 12 | 13 | export default async function Leaderboard() { 14 | 15 | const sessionState = await getCurrentUser(); 16 | 17 | if(!sessionState || !sessionState.profile) { 18 | redirect("/auth"); 19 | } 20 | 21 | const initProfiles = await getProfilesInRank({ 22 | rankID: sessionState.profile?.rank?.id, 23 | offset: 0, 24 | limit: 20 25 | }); 26 | 27 | const ranks = await getRanks(); 28 | 29 | const nextRank = ranks.find(rank => rank.xp_threshold > sessionState.profile!.total_xp); 30 | 31 | return ( 32 | <> 33 |
34 |
35 |

Leaderboard

36 | {nextRank && 37 | 42 | } 43 |
44 | {ranks.map((rank) => ( 45 |
49 | {rank.title} 50 | {rank.xp_threshold.toLocaleString()} XP 51 |
52 | ))} 53 |
54 | 55 |
56 | 57 |
58 | 64 |
65 | 66 |
67 | 68 | 69 | ) 70 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | } 10 | 11 | .material-symbols-filled { 12 | font-variation-settings: "FILL" 1; 13 | } 14 | 15 | .uppy-Dashboard-inner, .uppy-Dashboard-inner * { 16 | background: transparent !important; 17 | border: none !important; 18 | } 19 | 20 | .uppy-Dashboard-AddFiles-title { 21 | color: black !important; 22 | } 23 | 24 | .uppy-Dashboard-AddFiles-info { 25 | display: none !important; 26 | } 27 | 28 | /* dark theme */ 29 | @media screen and (prefers-color-scheme: dark) { 30 | .uppy-Dashboard-AddFiles-title, .uppy-DashboardContent-title, .uppy-Dashboard-AddFiles-info, .uppy-Dashboard-Item-fileInfo { 31 | color: white !important; 32 | } 33 | } 34 | 35 | @media screen and (max-width: 500px) { 36 | .uppy-Dashboard-inner { 37 | width: 90vw !important; 38 | } 39 | } 40 | 41 | @media screen and (max-width: 320px) { 42 | .uppy-Dashboard-inner { 43 | width: 87vw !important; 44 | } 45 | } 46 | 47 | .noise { 48 | display: flex; 49 | flex-direction: column; 50 | height: 100%; 51 | display: flex; 52 | position: relative; 53 | min-height: max-content; 54 | } 55 | 56 | .noise > div { 57 | flex: 1; 58 | display: flex; 59 | flex-direction: column; 60 | height: 100%; 61 | position: relative; 62 | min-height: max-content !important; 63 | overflow: hidden; 64 | min-width: initial !important; 65 | width: inherit !important; 66 | } 67 | 68 | .noise canvas { 69 | z-index: -2; 70 | top: 0; 71 | left: 0; 72 | position: absolute; 73 | height: 100vh; 74 | width: 100% !important; 75 | } 76 | 77 | .noise > div > div { 78 | flex: 1; 79 | position: relative; 80 | height: 100% !important; 81 | min-height: max-content !important; 82 | min-width: initial !important; 83 | width: inherit !important; 84 | } 85 | 86 | .noise-content { 87 | height: 100% !important; 88 | min-height: max-content !important; 89 | } 90 | 91 | .card-noise canvas { 92 | width: 100% !important; 93 | } 94 | 95 | .swiper-wrapper, .swiper-slide, .swiper { 96 | overflow: visible !important; 97 | } 98 | 99 | .swiper-scrollbar { 100 | @apply bg-primary/10 !important; 101 | transform: translateY(400%); 102 | } 103 | 104 | .swiper-scrollbar-drag { 105 | @apply bg-primary/50 rounded-full !important; 106 | } 107 | 108 | .recharts-area-area { 109 | fill: inherit !important; 110 | } 111 | 112 | .recharts-area-curve { 113 | stroke: inherit !important; 114 | } 115 | 116 | .recharts-cartesian-grid line { 117 | stroke: inherit !important; 118 | } 119 | 120 | @layer base { 121 | :root { 122 | --radius: 0.5rem; 123 | } 124 | } 125 | 126 | -------------------------------------------------------------------------------- /components/user/EditProfileCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from "react"; 4 | import { Card, CardHeader, CardBody, CardFooter } from "@nextui-org/card"; 5 | import { Input } from "@nextui-org/input"; 6 | import {Skeleton} from "@nextui-org/skeleton"; 7 | 8 | import { SessionState } from "@/types/auth"; 9 | import { Button } from "@/components/utils/Button"; 10 | import { Profile } from "@/types/db"; 11 | import Icon from "@/components/ui/Icon"; 12 | import { upsertProfile } from "@/utils/supabase/auth"; 13 | 14 | export default function EditProfileCard({ sessionState } : { sessionState: SessionState }) { 15 | const [userProfile, setUserProfile] = useState(sessionState.profile); 16 | const [isSaveLoading, setIsSaveLoading] = useState(false); 17 | const [isSaved, setIsSaved] = useState(false); 18 | 19 | useEffect(() => { 20 | setUserProfile(sessionState.profile); 21 | }, [sessionState]) 22 | 23 | const handleSaveProfile = async () => { 24 | if(!userProfile) return; 25 | 26 | setIsSaveLoading(true); 27 | setIsSaved(false); 28 | const res = await upsertProfile(userProfile); 29 | if(res) { 30 | setIsSaved(true); 31 | } 32 | setIsSaveLoading(false); 33 | } 34 | 35 | return ( 36 | <> 37 | 38 | Edit Profile 39 | 40 | 41 | 45 | person
} 49 | onChange={e => userProfile && setUserProfile({...userProfile, username: e.target.value})} 50 | /> 51 | 52 | 53 | 54 |
55 | 63 | {isSaved && Refresh app to see effect } 64 |
65 | 66 |
67 | 68 | 69 | ) 70 | } -------------------------------------------------------------------------------- /components/user/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import { formatReadableDate } from "@/utils/functions/helpers"; 2 | import { Button } from "@/components/utils/Button"; 3 | import Icon from "@/components/ui/Icon"; 4 | 5 | import FollowButton from "../ui/FollowButton"; 6 | import { Profile } from "@/types/db"; 7 | import { SessionState } from "@/types/auth"; 8 | import ProfileStreak from "./ProfileStreak"; 9 | 10 | export default function ProfileCard({ profile, session } : { profile: Profile, session: SessionState }) {; 11 | 12 | return ( 13 | <> 14 |
15 | 16 |
17 | 18 |
19 | 20 |
21 |
22 |
23 |

{profile?.username}

24 |
25 | 26 |
27 | {profile.username} 28 | Joined {formatReadableDate(profile.created_at ?? "")} 29 |
30 |
31 |
32 | 33 |
34 |

Overview

35 | 36 |
37 | 38 | 39 | 40 | 41 |
42 |
43 | hotel_class 44 |
45 |
46 | {profile.total_xp} 47 |
48 |
49 | 50 | 51 |
52 | 53 |
54 | 55 |
56 | {session?.user?.id && session.user.id !== profile.id && } 57 | 58 |
59 | 60 |
61 | 62 |
63 | 64 | 65 |
66 | 67 | 68 | ) 69 | } -------------------------------------------------------------------------------- /components/levelScroller/AddContentModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Link from "next/link"; 5 | 6 | import { Button } from "@/components/utils/Button"; 7 | import BlurModal from "../ui/BlurModal"; 8 | import Icon from "@/components/ui/Icon"; 9 | import { Course } from "@/types/db"; 10 | 11 | type Props = { 12 | course: Course 13 | } 14 | 15 | export default function AddContentModal(props: Props) { 16 | const [isModalOpen, setIsModalOpen] = useState(false); 17 | 18 | return ( 19 | <> 20 | 27 | 28 | Add Content to {props.course.abbreviation}} 37 | body={ 38 | <> 39 |
40 | 41 |
42 | 43 | 46 | 47 |
48 | 49 |
50 | 51 | 57 | 58 | 59 | 65 | 66 |
67 | 68 |
69 | 70 | } 71 | footer={ 72 | <> 73 | Footer 74 | 75 | } 76 | /> 77 | 78 | ) 79 | } -------------------------------------------------------------------------------- /components/training/ViewTrainings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | 5 | import { Training } from "@/types/db"; 6 | import { Button } from "../utils/Button"; 7 | import Icon from "@/components/ui/Icon"; 8 | import BlurModal from "../ui/BlurModal"; 9 | import TrainingCard from "./TrainingCard"; 10 | import InfiniteScroll from "react-infinite-scroller"; 11 | import { getTrainings } from "@/utils/supabase/trainings"; 12 | import { Spinner } from "@nextui-org/spinner"; 13 | 14 | const _limit = 20; 15 | 16 | export default function ViewTrainings() { 17 | 18 | // infinite scroll 19 | const [cursor, setCursor] = useState(0); 20 | const [trainings, setTrainings] = useState([]); 21 | const [isLoading, setIsLoading] = useState(false); 22 | const [canLoadMore, setCanLoadMore] = useState(true); 23 | 24 | const [isModalOpen, setIsModalOpen] = useState(false); 25 | 26 | const handleLoadMore = async () => { 27 | if(isLoading) return; 28 | 29 | setIsLoading(true); 30 | 31 | const result = await getTrainings({ from: cursor, limit: _limit }); 32 | 33 | if(result.length === 0) { 34 | setIsLoading(false); 35 | setCanLoadMore(false); 36 | return; 37 | } 38 | 39 | if(result.length < _limit) { 40 | setCanLoadMore(false); 41 | } 42 | 43 | setTrainings([...trainings, ...result]); 44 | setCursor(cursor + _limit); 45 | 46 | setIsLoading(false); 47 | 48 | } 49 | 50 | return ( 51 | <> 52 | 61 | 62 | Trainings

} 72 | body={ 73 | <> 74 | await handleLoadMore()} 76 | hasMore={canLoadMore} 77 | pageStart={1} 78 | loader={} 79 | className="flex flex-col gap-4 h-fit" 80 | > 81 | {trainings.map((training, i) => ( 82 | 83 | ))} 84 | 85 | 86 | } 87 | footer={ 88 | <> 89 | 90 | 91 | } 92 | /> 93 | 94 | ) 95 | } -------------------------------------------------------------------------------- /utils/supabase/trainings.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cache } from "react"; 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | 6 | import { createClient } from "./server/server"; 7 | import { Question, Training } from "@/types/db"; 8 | 9 | 10 | export const getTrainingById = cache(async (trainingId: string): Promise<{ training: Training, questions: Question[] }> => { 11 | const { data, error } = await createClient() 12 | .from("trainings") 13 | .select("*") 14 | .eq("id", trainingId) 15 | .select(` 16 | questions ( 17 | question_types (*), 18 | topics (*), 19 | * 20 | ), 21 | * 22 | `) 23 | .single() 24 | 25 | if (error) { 26 | throw error; 27 | } 28 | 29 | return { 30 | training: data as Training, 31 | questions: data.questions.map((db: any) => { 32 | return { 33 | ...db, 34 | type: db.question_types, 35 | topic: db.topics 36 | } 37 | }) 38 | } 39 | }) 40 | 41 | type GetTrainingsParams = { 42 | from: number; 43 | limit: number; 44 | } 45 | 46 | export const getTrainings = cache(async (params: GetTrainingsParams): Promise => { 47 | const { data, error } = await createClient() 48 | .from("trainings") 49 | .select("*") 50 | .order("created_at", { ascending: false }) 51 | .range(params.from, params.from + params.limit - 1); 52 | 53 | if (error) { throw error; } 54 | 55 | return data; 56 | }) 57 | 58 | 59 | // no cache 60 | 61 | export async function addTraining({ userId, questions } : { userId: string, questions: Question[] }): Promise { 62 | 63 | const { data, error } = await createClient() 64 | .from("trainings") 65 | .insert({ user: userId }) 66 | .eq("user", userId) 67 | .select("*") 68 | .single(); 69 | 70 | if (error) { 71 | throw error; 72 | } 73 | 74 | if(data) { 75 | 76 | const trainingId = data.id; 77 | 78 | await createClient() 79 | .from("trainings_questions") 80 | .insert([ ...questions.map((question) => ({ training: trainingId, question: question.id })) ]) 81 | .eq("training_id", trainingId) 82 | .select(); 83 | 84 | } 85 | 86 | return data; 87 | 88 | } 89 | 90 | export async function completeTraining({ trainingId, accuracy, seconds, xp } : { trainingId: string, accuracy: number, seconds: number, xp: number }): Promise { 91 | const { data, error } = await createClient() 92 | .from("trainings") 93 | .update({ accuracy, seconds, xp, completed: true }) 94 | .eq("id", trainingId) 95 | .select("*") 96 | .single(); 97 | 98 | if (error) { 99 | throw error; 100 | } 101 | 102 | return data; 103 | } -------------------------------------------------------------------------------- /utils/supabase/streaks.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { cache } from "react"; 4 | /* eslint-disable @typescript-eslint/no-explicit-any */ 5 | 6 | import { getDayBefore, isSameDay } from "../functions/helpers"; 7 | import { createClient as getClient } from "./server/server"; 8 | 9 | import { Streak } from "@/types/db"; 10 | 11 | 12 | export const getStreaks = cache(async(userID: string) => { 13 | const { data, error } = await getClient().from("streaks").select().eq("user", userID); 14 | if(error) { throw error; } 15 | return data; 16 | }) 17 | 18 | export const getCurrentStreak = cache(async(userID: string, date: Date): Promise => { 19 | const { data, error } = await getClient() 20 | .from("streaks") 21 | .select() 22 | .eq("user", userID) 23 | .gte("to", getDayBefore(date).toISOString()) // to >= yesterday <- gets hanging or current streak 24 | if(error) { 25 | if(error.details == "The result contains 0 rows") { 26 | return null; 27 | } 28 | throw error; 29 | } 30 | return data[0] as Streak; 31 | 32 | }) 33 | 34 | 35 | // no cache 36 | 37 | export async function extendOrAddStreak(userID: string, today: Date): Promise<{ streak: Streak, isExtended: boolean, isAdded: boolean }> { 38 | const { data, error } = await getClient().from("streaks").select().eq("user", userID).order("from", { ascending: false }).limit(1); 39 | if(error) { throw error; } 40 | 41 | if(data.length === 0) { 42 | // no streaks, add a new one 43 | const { data: db, error: addError } = await getClient().from("streaks").insert([ 44 | { 45 | user: userID, 46 | from: today, 47 | to: today 48 | } 49 | ]).select(); 50 | if(addError) { throw addError; } 51 | return { streak: db[0], isExtended: false, isAdded: true }; 52 | } else { 53 | // check if the streak can be extended 54 | const lastStreak = data[0] as any as Streak; 55 | const lastStreakTo = new Date(lastStreak.to as string & undefined); 56 | 57 | // streak is ongoing, dont do anything 58 | if(isSameDay(lastStreakTo, today)) { 59 | return { streak: lastStreak, isExtended: false, isAdded: false }; 60 | } 61 | 62 | if(isSameDay(lastStreakTo, getDayBefore(today))) { 63 | // extend the streak 64 | const { data: db, error: extendError } = await getClient().from("streaks").update({ to: today }).eq("id", lastStreak.id).select(); 65 | if(extendError) { throw extendError; } 66 | return { streak: db[0], isExtended: true, isAdded: false }; 67 | } else { 68 | // add a new streak 69 | const { data: db, error: addError } = await getClient().from("streaks").insert([ 70 | { 71 | user: userID, 72 | from: today, 73 | to: today 74 | } 75 | ]).select(); 76 | if(addError) { throw addError; } 77 | return { streak: db[0], isExtended: false, isAdded: true }; 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /components/level/question/questionTypes/EditFillBlank.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Textarea } from "@nextui-org/input"; 4 | 5 | import { Button } from "@/components/utils/Button"; 6 | import Icon from "@/components/ui/Icon"; 7 | import { Question } from "@/types/db"; 8 | import { useEffect, useState } from "react"; 9 | 10 | type Props = { 11 | question: Question; 12 | handleUpdateValue: (key: keyof Question, value: string | string[]) => void; 13 | handleOptionValueChange: (value: string, index: number) => void; 14 | } 15 | 16 | export default function EditFillBlank({ question, handleUpdateValue }: Props) { 17 | const [selectMode, setSelectMode] = useState(false); 18 | const [text, setText] = useState(question.answers_correct.join(" ")); 19 | 20 | const handleRemoveBlank = (word: string) => { 21 | handleUpdateValue("answer_options", question.answer_options.filter((o) => o != word)); 22 | } 23 | 24 | const handleAddBlank = (word: string) => { 25 | handleUpdateValue("answer_options", [...question.answer_options, word]); 26 | } 27 | 28 | const handleToogleBlank = (word: string) => { 29 | 30 | // If the word is already in the answer options, remove it 31 | if(question.answer_options.includes(word)) { 32 | handleRemoveBlank(word); 33 | } else { 34 | handleAddBlank(word); 35 | } 36 | } 37 | 38 | // keep the answers_correct in sync with the text 39 | useEffect(() => { 40 | handleUpdateValue("answers_correct", text.split(" ")); 41 | }, [text]) 42 | 43 | 44 | return ( 45 |
46 | Options 47 | 48 | { !selectMode && 49 |