├── .env.example ├── .eslintrc.json ├── .gitignore ├── LICENSE.md ├── README.md ├── app ├── about │ └── page.tsx ├── account │ ├── courses │ │ ├── components │ │ │ └── course-item.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── owned-courses │ │ ├── components │ │ │ └── course-item.tsx │ │ └── page.tsx │ └── profile │ │ ├── components │ │ ├── account-form.tsx │ │ ├── account-profile-dynamic.ts │ │ └── account-profile.tsx │ │ └── page.tsx ├── api │ ├── chat │ │ ├── route.ts │ │ └── utils.ts │ ├── courses │ │ ├── [courseId] │ │ │ ├── checkout │ │ │ │ └── route.ts │ │ │ ├── enroll │ │ │ │ └── route.ts │ │ │ ├── subscribe │ │ │ │ └── route.ts │ │ │ ├── unenroll │ │ │ │ └── route.ts │ │ │ └── unsubscribe │ │ │ │ └── route.ts │ │ ├── outline │ │ │ ├── helpers │ │ │ │ ├── predict-decoder-stream.ts │ │ │ │ ├── sse-decoder-stream.ts │ │ │ │ ├── sse-json-decoder-stream.ts │ │ │ │ ├── text-decoder-stream.ts │ │ │ │ ├── text-encoder-stream.ts │ │ │ │ └── types.ts │ │ │ └── route.ts │ │ └── route.ts │ ├── redirect │ │ ├── courses │ │ │ └── [courseId] │ │ │ │ ├── first-unit │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ └── units │ │ │ └── [unitId] │ │ │ ├── next │ │ │ └── route.ts │ │ │ └── route.ts │ ├── search │ │ └── route.ts │ ├── units │ │ └── [unitId] │ │ │ ├── complete │ │ │ └── route.ts │ │ │ └── route.ts │ ├── users │ │ └── me │ │ │ └── route.ts │ └── webhooks │ │ └── stripe │ │ └── route.ts ├── auth │ ├── complete │ │ └── page.tsx │ ├── components │ │ ├── account-auth-dynamic.ts │ │ └── account-auth.tsx │ └── page.tsx ├── courses │ ├── [courseSlug] │ │ ├── layout.tsx │ │ ├── modules │ │ │ └── [moduleSlug] │ │ │ │ ├── page.tsx │ │ │ │ └── units │ │ │ │ └── [unitSlug] │ │ │ │ ├── edit │ │ │ │ ├── components │ │ │ │ │ └── edit-unit-form.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── unsubscribe │ │ │ ├── components │ │ │ └── course-unsubscribe-form.tsx │ │ │ └── page.tsx │ ├── category │ │ └── [categorySlug] │ │ │ └── page.tsx │ ├── components │ │ ├── courses-grid.tsx │ │ ├── courses-layout.tsx │ │ ├── courses-nav.tsx │ │ └── courses-skeleton-grid.tsx │ ├── new │ │ ├── components │ │ │ ├── confirm-outline-form.tsx │ │ │ ├── generate-outline-form.tsx │ │ │ ├── language-select.tsx │ │ │ ├── new-course-manager.tsx │ │ │ ├── types.ts │ │ │ ├── user-manual.tsx │ │ │ └── utils.ts │ │ └── page.tsx │ └── page.tsx ├── favicon.ico ├── favicon.svg ├── globals.css ├── layout.tsx ├── page.tsx ├── payments │ ├── failure │ │ ├── components │ │ │ └── try-again-button.tsx │ │ └── page.tsx │ └── success │ │ └── page.tsx ├── privacy │ └── page.tsx ├── sign-out │ └── route.ts ├── terms │ └── page.tsx └── types.ts ├── components.json ├── components ├── chat-scroll-anchor.tsx ├── chat-sidebar │ ├── chat-message-content.tsx │ ├── chat-message.tsx │ ├── chat-sidebar-client.tsx │ ├── chat-sidebar.tsx │ ├── index.ts │ ├── types.ts │ └── utils.ts ├── course-sidebar │ ├── course-progress.tsx │ ├── course-sidebar-with-enrollment.tsx │ ├── course-sidebar.tsx │ ├── course-subscribe.tsx │ ├── index.ts │ └── unit-list-item.tsx ├── course-units │ ├── complete-button │ │ ├── complete-button-client.tsx │ │ ├── complete-button.tsx │ │ └── index.ts │ ├── course-unit-skeleton.tsx │ ├── course-unit.tsx │ ├── edit-button.tsx │ ├── unit-content.tsx │ ├── unit-footer.tsx │ ├── unit-image.tsx │ └── unit-pagination.tsx ├── courses │ ├── command-dialog │ │ ├── command-dialog.tsx │ │ ├── index.ts │ │ └── utils.ts │ ├── course-card.tsx │ ├── course-generating.tsx │ └── enroll-button │ │ ├── enroll-button-client.tsx │ │ ├── enroll-button.tsx │ │ └── index.ts ├── error-boundary.tsx ├── header │ ├── header.tsx │ ├── index.ts │ ├── search-button.tsx │ ├── search-input.tsx │ ├── theme-button.tsx │ └── user-nav │ │ ├── user-nav-client.tsx │ │ └── user-nav.tsx ├── image-dialog.tsx ├── layouts │ ├── course-sidebar-layout.tsx │ └── header-layout.tsx ├── scrolling-textarea.tsx ├── session-content │ └── session-content.tsx ├── sidebar-nav.tsx ├── theme-provider.tsx ├── tooltip.tsx └── ui │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── codeblock.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── markdown.ts │ ├── progress.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── skeleton.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── lib ├── assert.ts ├── cip-category.ts ├── description.ts ├── enum.ts ├── format-distance.ts ├── generate-id.ts ├── json-fetch.ts ├── lodash-fns.ts ├── lodash-memoize.ts ├── not-empty.ts ├── readline.ts ├── resend.ts ├── sleep.ts ├── slugify.ts ├── titlize.tsx ├── use-abort-controller.tsx ├── use-debounced-state.ts ├── use-event-listener.ts ├── use-keyboard-shortcut.ts ├── use-loading.ts ├── use-message.ts ├── use-read-text-stream.ts ├── utils.ts └── uuid.ts ├── next.config.js ├── package.json ├── pages └── api │ ├── dev │ └── emails │ │ ├── course-created.ts │ │ └── course-unit.ts │ └── inngest.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public └── static │ ├── logo.png │ └── twitter.png ├── schema.sql ├── scripts ├── backfill-course-cip.ts ├── backfill-parse-ddc.ts ├── generate-course.ts ├── generate-sample-course.ts ├── generate-sample-module.ts ├── generate-sample-unit.ts ├── parse-sample-course-cip.ts └── test-course-units-map.ts ├── server ├── db │ ├── course_subscriptions │ │ ├── getters.ts │ │ └── setters.ts │ ├── courses │ │ ├── getters.ts │ │ ├── setters.ts │ │ └── types.ts │ ├── db.ts │ ├── edge-db.ts │ ├── enrollment │ │ ├── getters.ts │ │ ├── setters.ts │ │ └── types.ts │ ├── modules │ │ ├── getters.ts │ │ ├── setters.ts │ │ └── types.ts │ ├── schema.ts │ ├── unit_chats │ │ ├── getters.ts │ │ ├── setters.ts │ │ └── types.ts │ ├── units │ │ ├── getters.ts │ │ ├── setters.ts │ │ └── types.ts │ └── users │ │ ├── getters.ts │ │ └── setters.ts ├── emails │ ├── components │ │ └── container.tsx │ ├── course-created-email.tsx │ ├── course-unit-email.tsx │ ├── styles.ts │ └── utils.ts ├── helpers │ ├── ai │ │ └── prompts │ │ │ ├── generate-course.ts │ │ │ ├── generate-module.ts │ │ │ ├── generate-unit.ts │ │ │ ├── generate-wikipedia-links.test.ts │ │ │ ├── generate-wikipedia-links.ts │ │ │ ├── parse-course-cip.test.ts │ │ │ ├── parse-course-cip.ts │ │ │ ├── parse-course-ddc.ts │ │ │ └── parse-course.ts │ ├── api-builder │ │ ├── api-builder.ts │ │ ├── index.ts │ │ └── types.ts │ ├── auth │ │ ├── index.ts │ │ ├── session.ts │ │ └── token.ts │ ├── base-url.ts │ ├── course-subscription.ts │ ├── error.ts │ ├── links.ts │ ├── params-getters.ts │ ├── params.ts │ └── slug.ts ├── jobs │ ├── client.ts │ └── functions │ │ ├── course-generate │ │ ├── course-generate.ts │ │ ├── index.ts │ │ ├── step-generate-module.ts │ │ ├── step-generate-unit.ts │ │ ├── step-parse-course.ts │ │ └── step-send-email.ts │ │ └── course-subscribe │ │ ├── course-subscribe.ts │ │ ├── index.ts │ │ └── step-send-email.ts ├── lib │ ├── anthropic │ │ ├── client.ts │ │ ├── completion.test.ts │ │ ├── completion.ts │ │ ├── functions.test.ts │ │ ├── functions.ts │ │ └── types.ts │ ├── id.ts │ ├── open-ai │ │ ├── client.ts │ │ ├── completion.ts │ │ ├── functions.ts │ │ ├── index.ts │ │ └── types.ts │ ├── response-json.ts │ ├── wikipedia.test.ts │ ├── wikipedia.ts │ └── zod-fns.ts └── payments │ └── stripe.ts ├── tailwind.config.js ├── tsconfig.json ├── types └── custom-event.d.ts └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_HANKO_API_URL=https://example.hanko.io 2 | DATABASE_URL=postgresql://username:password@localhost:5432/mydatabase 3 | OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx 4 | RESEND_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx 5 | APP_HOST=myapp.example.com 6 | # VERCEL_URL is automatically provided by Vercel, no need to set it manually 7 | STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxx 8 | STRIPE_PRICE_ID=price_xxxxxxxxxxxxxxxxxx 9 | STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxx -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "plugins": ["@typescript-eslint"], 4 | "rules": { 5 | "import/order": [ 6 | "warn", 7 | { 8 | "newlines-between": "always", 9 | "alphabetize": { "order": "asc" }, 10 | "groups": [ 11 | "builtin", 12 | "external", 13 | "internal", 14 | ["parent", "sibling"], 15 | "index", 16 | "type" 17 | ] 18 | } 19 | ], 20 | 21 | "no-unused-vars": "off", 22 | 23 | "@typescript-eslint/no-unused-vars": [ 24 | "error", 25 | { 26 | "argsIgnorePattern": "^_", 27 | "varsIgnorePattern": "^_", 28 | "caughtErrorsIgnorePattern": "^_" 29 | } 30 | ], 31 | 32 | "@next/next/no-img-element": "off" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env.test 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | .vscode 40 | TODO.md 41 | 42 | server/db/schema-generated.ts 43 | 44 | .react-email -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alex MacCaw 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/about/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { HeaderLayout } from '@/components/layouts/header-layout' 4 | 5 | export default function About() { 6 | return ( 7 | 8 |
9 |

About

10 |

101.school is an experiment in creating AI generated course contents.

11 | 12 |

13 | It works like this: you enter something you're curious about and then we 14 | generate a course on the subject. The course is generated by GPT-4 based on your 15 | input. 16 |

17 | 18 |

19 | You can choose to receive the course via email, or read it on the site. 20 | We'll keep track of your progress. 21 |

22 | 23 |

24 | You can also chat with the AI to ask it questions about any given unit in the 25 | course. 26 |

27 | 28 |

29 | An Alex MacCaw project. 30 |

31 | 32 | 41 |
42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/account/courses/components/course-item.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/navigation' 5 | 6 | import { Button, buttonVariants } from '@/components/ui/button' 7 | import { toast } from '@/components/ui/use-toast' 8 | import { cn } from '@/lib/utils' 9 | import { CourseWithImage } from '@/server/db/courses/types' 10 | 11 | export function CourseItem({ course }: { course: CourseWithImage }) { 12 | const router = useRouter() 13 | 14 | async function handleUnEnroll() { 15 | await fetchUnEnroll(course.id) 16 | 17 | toast({ 18 | title: 'Un-enrolled', 19 | description: `You have un-enrolled from '${course.title}'`, 20 | }) 21 | 22 | router.refresh() 23 | } 24 | 25 | return ( 26 |
27 | {course.image?.source && ( 28 |
29 | {course.title} 34 |
35 | )} 36 | 37 |
{course.title}
38 | 39 |
40 | {course.description && ( 41 |

42 | {course.description} 43 |

44 | )} 45 |
46 | 47 |
48 | 52 | View course 53 | 54 | 55 | 58 |
59 |
60 | ) 61 | } 62 | 63 | function fetchUnEnroll(courseId: string) { 64 | return fetch(`/api/courses/${courseId}/unenroll`, { 65 | method: 'POST', 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /app/account/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { buttonVariants } from '@/components/ui/button' 4 | import { cn } from '@/lib/utils' 5 | import { getCoursesByEnrolledUser } from '@/server/db/courses/getters' 6 | import { authOrRedirect } from '@/server/helpers/auth' 7 | 8 | import { CourseItem } from './components/course-item' 9 | 10 | export default async function AccountCourses() { 11 | const userId = await authOrRedirect() 12 | const courses = await getCoursesByEnrolledUser(userId) 13 | 14 | return ( 15 |
16 | {courses.length === 0 && ( 17 |
18 |

You haven't enrolled in any courses yet.

19 | 20 | 21 | See all courses... 22 | 23 |
24 | )} 25 | 26 |
27 | {courses.map((course) => ( 28 | 29 | ))} 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/account/layout.tsx: -------------------------------------------------------------------------------- 1 | import { HeaderLayout } from '@/components/layouts/header-layout' 2 | import { SidebarNav } from '@/components/sidebar-nav' 3 | import { Separator } from '@/components/ui/separator' 4 | 5 | const sidebarNavItems = [ 6 | { 7 | title: 'Enrolled courses', 8 | href: '/account/courses', 9 | }, 10 | { 11 | title: 'Created courses', 12 | href: '/account/owned-courses', 13 | }, 14 | { 15 | title: 'Profile', 16 | href: '/account/profile', 17 | }, 18 | ] 19 | 20 | interface SettingsLayoutProps { 21 | children: React.ReactNode 22 | } 23 | 24 | export default function SettingsLayout({ children }: SettingsLayoutProps) { 25 | return ( 26 | 27 |
28 |
29 |

Account

30 |

31 | Manage your account and which courses you are enrolled in. 32 |

33 |
34 | 35 | 36 | 37 |
38 | 41 |
{children}
42 |
43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /app/account/owned-courses/components/course-item.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | 5 | import { buttonVariants } from '@/components/ui/button' 6 | import { cn } from '@/lib/utils' 7 | import { CourseWithImage } from '@/server/db/courses/types' 8 | 9 | export function CourseItem({ course }: { course: CourseWithImage }) { 10 | return ( 11 |
12 | {course.image?.source && ( 13 |
14 | {course.title} 19 |
20 | )} 21 | 22 |
{course.title}
23 | 24 |
25 | {course.description && ( 26 |

27 | {course.description} 28 |

29 | )} 30 |
31 | 32 |
33 | 37 | View course 38 | 39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/account/owned-courses/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { buttonVariants } from '@/components/ui/button' 4 | import { cn } from '@/lib/utils' 5 | import { getCoursesByOwner } from '@/server/db/courses/getters' 6 | import { authOrRedirect } from '@/server/helpers/auth' 7 | 8 | import { CourseItem } from './components/course-item' 9 | 10 | export default async function AccountCreatedCourses() { 11 | const userId = await authOrRedirect() 12 | const courses = await getCoursesByOwner(userId) 13 | 14 | return ( 15 |
16 | {courses.length === 0 && ( 17 |
18 |

You haven't created any courses yet.

19 | 20 | 24 | Generate a new course... 25 | 26 |
27 | )} 28 | 29 |
30 | {courses.map((course) => ( 31 | 32 | ))} 33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/account/profile/components/account-profile-dynamic.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | 5 | export const AccountProfile = dynamic(() => import('./account-profile'), { ssr: false }) 6 | -------------------------------------------------------------------------------- /app/account/profile/components/account-profile.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { register } from '@teamhanko/hanko-elements' 4 | import React, { useEffect } from 'react' 5 | 6 | import { assertString } from '@/lib/assert' 7 | 8 | const hankoApiUrl = process.env.NEXT_PUBLIC_HANKO_API_URL 9 | 10 | export default function AccountProfile() { 11 | assertString(hankoApiUrl, 'Hanko API URL is not defined.') 12 | 13 | useEffect(() => { 14 | // register the component 15 | // see: https://github.com/teamhanko/hanko/blob/main/frontend/elements/README.md#script 16 | register(hankoApiUrl) 17 | }, []) 18 | 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /app/account/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from '@/components/ui/separator' 2 | import { assert } from '@/lib/assert' 3 | import { getUser } from '@/server/db/users/getters' 4 | import { authOrRedirect } from '@/server/helpers/auth' 5 | 6 | import { AccountForm } from './components/account-form' 7 | import { AccountProfile } from './components/account-profile-dynamic' 8 | 9 | export default async function Account() { 10 | const userId = await authOrRedirect() 11 | const user = await getUser(userId) 12 | assert(user, 'User not found') 13 | 14 | return ( 15 |
16 |
17 |

Profile

18 |

19 | This is how others will see you on the site. 20 |

21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { AnthropicStream, Message, StreamingTextResponse } from 'ai' 2 | import Anthropic from '@anthropic-ai/sdk' 3 | 4 | import { upsertUnitChat } from '@/server/db/unit_chats/setters' 5 | import { withAuth } from '@/server/helpers/auth' 6 | import { UnitChatMessage } from '@/server/db/schema' 7 | import { 8 | chatMessageToUnitMessage, 9 | getSystemPrompt, 10 | messagesToAnthropicMessage, 11 | } from './utils' 12 | 13 | const anthropic = new Anthropic({ 14 | apiKey: process.env.ANTHROPIC_API_KEY || '', 15 | }) 16 | 17 | export const runtime = 'edge' 18 | 19 | async function createChat(req: Request, { userId }: { userId: string }) { 20 | const { messages: messagesParam, unitId } = (await req.json()) as { 21 | messages: Message[] 22 | unitId: string 23 | } 24 | 25 | const systemPrompt = getSystemPrompt(messagesParam) 26 | 27 | const anthopicMessages = messagesToAnthropicMessage(messagesParam) 28 | 29 | const response = await anthropic.messages.create({ 30 | system: systemPrompt, 31 | messages: anthopicMessages, 32 | model: 'claude-3-opus-20240229', 33 | stream: true, 34 | max_tokens: 4096, 35 | }) 36 | 37 | const stream = AnthropicStream(response, { 38 | onCompletion: async (incomingMessageContent) => { 39 | const existingMessages: UnitChatMessage[] = messagesParam.map( 40 | chatMessageToUnitMessage, 41 | ) 42 | 43 | const newMessage: UnitChatMessage = { 44 | content: incomingMessageContent, 45 | role: 'assistant', 46 | createdAt: new Date().toISOString(), 47 | } 48 | 49 | await upsertUnitChat({ 50 | unitId, 51 | userId, 52 | messages: [...existingMessages, newMessage], 53 | }) 54 | }, 55 | }) 56 | return new StreamingTextResponse(stream) 57 | } 58 | 59 | export const POST = withAuth(createChat) 60 | -------------------------------------------------------------------------------- /app/api/chat/utils.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'ai' 2 | import { UnitChatMessage } from '@/server/db/schema' 3 | import { MessageParam } from '@anthropic-ai/sdk/resources' 4 | 5 | export function chatMessageToUnitMessage(message: Message): UnitChatMessage { 6 | return { 7 | id: message.id, 8 | content: message.content ?? '', 9 | role: message.role ?? 'assistant', 10 | createdAt: message.createdAt 11 | ? new Date(message.createdAt).toISOString() 12 | : new Date().toISOString(), 13 | } 14 | } 15 | 16 | export function messagesToAnthropicMessage(messages: Message[]): MessageParam[] { 17 | const result: MessageParam[] = [] 18 | let seenUser = false 19 | 20 | for (const message of messages) { 21 | if (message.role === 'user') { 22 | seenUser = true 23 | } 24 | 25 | // First message must have the `role` of the user 26 | if (!seenUser) { 27 | continue 28 | } 29 | 30 | if (message.role === 'assistant' || message.role === 'user') { 31 | result.push({ 32 | role: message.role, 33 | content: message.content, 34 | }) 35 | } 36 | } 37 | 38 | return result 39 | } 40 | 41 | export function getSystemPrompt(messages: Message[]): string { 42 | return messages 43 | .filter((message) => message.role === 'system') 44 | .map((message) => message.content) 45 | .join('\n') 46 | } 47 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/checkout/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import Stripe from 'stripe' 3 | 4 | import { assertString } from '@/lib/assert' 5 | import { withAuth } from '@/server/helpers/auth' 6 | import { stripe } from '@/server/payments/stripe' 7 | 8 | const priceId = process.env.STRIPE_PRICE_ID 9 | 10 | async function createCheckoutSession( 11 | request: Request, 12 | { userId, params }: { userId: string; params: { courseId: string } }, 13 | ) { 14 | const { courseId } = params 15 | 16 | const origin = request.headers.get('origin') 17 | 18 | const sessionParams: Stripe.Checkout.SessionCreateParams = { 19 | mode: 'payment', 20 | client_reference_id: userId, 21 | payment_intent_data: { 22 | metadata: { 23 | courseId, 24 | }, 25 | }, 26 | line_items: [ 27 | { 28 | price: priceId, 29 | quantity: 1, 30 | }, 31 | ], 32 | success_url: `${origin}/payments/success?courseId=${courseId}`, 33 | cancel_url: `${origin}/payments/failure?courseId=${courseId}`, 34 | } 35 | 36 | console.log('[stripe] Creating session', { sessionParams }) 37 | 38 | const checkoutSession: Stripe.Checkout.Session = await stripe.checkout.sessions.create( 39 | sessionParams, 40 | ) 41 | 42 | assertString(checkoutSession.url) 43 | 44 | console.log('[stripe] Created session', { checkoutSession }) 45 | console.log('[stripe] Redirecting to checkout session', { url: checkoutSession.url }) 46 | 47 | return NextResponse.json({ checkoutUrl: checkoutSession.url }) 48 | } 49 | 50 | export const POST = withAuth(createCheckoutSession) 51 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/enroll/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { enrollInCourse } from '@/server/db/enrollment/setters' 4 | import { withAuth } from '@/server/helpers/auth' 5 | 6 | async function coursesEnroll( 7 | request: Request, 8 | { params, userId }: { params: { courseId: string }; userId: string }, 9 | ) { 10 | await enrollInCourse({ userId, courseId: params.courseId }) 11 | 12 | return NextResponse.json({ success: true }) 13 | } 14 | 15 | export const POST = withAuth(coursesEnroll) 16 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/subscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { enrollInCourse } from '@/server/db/enrollment/setters' 4 | import { withUnenforcedAuth } from '@/server/helpers/auth' 5 | import { subscribeEmailToCourse } from '@/server/helpers/course-subscription' 6 | import { error } from '@/server/helpers/error' 7 | 8 | async function handleCourseSubscribe( 9 | request: Request, 10 | { userId, params }: { userId?: string; params: { courseId: string } }, 11 | ) { 12 | const { email, daysInterval = 7 } = await request.json() 13 | 14 | // 1. Create course subscription, if it doesn't exist 15 | try { 16 | await subscribeEmailToCourse({ 17 | userId: userId ?? null, 18 | courseId: params.courseId, 19 | email, 20 | daysInterval, 21 | }) 22 | } catch (err: any) { 23 | console.error(err) 24 | return error('Error creating subscription') 25 | } 26 | 27 | // 2. Enroll user in course if they are logged in 28 | if (userId) { 29 | await enrollInCourse({ 30 | userId, 31 | courseId: params.courseId, 32 | }) 33 | } 34 | 35 | return NextResponse.json({ 36 | success: true, 37 | }) 38 | } 39 | 40 | export const POST = withUnenforcedAuth(handleCourseSubscribe) 41 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/unenroll/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { unenrollFromCourse } from '@/server/db/enrollment/setters' 4 | import { getUser } from '@/server/db/users/getters' 5 | import { withAuth } from '@/server/helpers/auth' 6 | import { unsubscribeEmailFromCourse } from '@/server/helpers/course-subscription' 7 | 8 | async function coursesUnenroll( 9 | request: Request, 10 | { params, userId }: { params: { courseId: string }; userId: string }, 11 | ) { 12 | await unenrollFromCourse({ userId, courseId: params.courseId }) 13 | 14 | const user = await getUser(userId) 15 | 16 | for (const email of user?.emails ?? []) { 17 | await unsubscribeEmailFromCourse({ 18 | email, 19 | courseId: params.courseId, 20 | }) 21 | } 22 | 23 | return NextResponse.json({ success: true }) 24 | } 25 | 26 | export const POST = withAuth(coursesUnenroll) 27 | -------------------------------------------------------------------------------- /app/api/courses/[courseId]/unsubscribe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { getCourseSubscriptionByCourseEmail } from '@/server/db/course_subscriptions/getters' 4 | import { deleteCourseSubscription } from '@/server/db/course_subscriptions/setters' 5 | import { error } from '@/server/helpers/error' 6 | import { inngest } from '@/server/jobs/client' 7 | 8 | export async function POST( 9 | request: Request, 10 | { params }: { params: { courseId: string } }, 11 | ) { 12 | const { email } = await request.json() 13 | 14 | if (!email) { 15 | return error('Email is required') 16 | } 17 | 18 | const courseSubscription = await getCourseSubscriptionByCourseEmail({ 19 | courseId: params.courseId, 20 | email, 21 | }) 22 | 23 | if (!courseSubscription) { 24 | return error('Subscription not found') 25 | } 26 | 27 | await inngest.send({ 28 | name: 'course/unsubscribe', 29 | data: { 30 | courseSubscriptionId: courseSubscription.id, 31 | }, 32 | }) 33 | 34 | await deleteCourseSubscription(courseSubscription.id) 35 | 36 | return NextResponse.json({ 37 | success: true, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/predict-decoder-stream.ts: -------------------------------------------------------------------------------- 1 | import { PredictResponse } from './types' 2 | 3 | export class AnthropicPredictDecoderStream extends TransformStream { 4 | constructor() { 5 | super({ 6 | transform: (chunk: PredictResponse, controller) => { 7 | if (chunk.type === 'content_block_delta' && chunk.delta?.text) { 8 | controller.enqueue(chunk.delta.text) 9 | return 10 | } 11 | 12 | if (chunk.type === 'message_stop') { 13 | controller.terminate() 14 | return 15 | } 16 | }, 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/sse-decoder-stream.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-constant-condition */ 2 | export class SSEDecoderStream extends TransformStream { 3 | constructor() { 4 | const delimiter = '\n\n' 5 | 6 | let buffer = '' 7 | 8 | super({ 9 | transform: (chunk: string, controller) => { 10 | // Server sent events come in with a colon separating the type from the data 11 | // and two newlines separating the data from the next event. Sometimes they are 12 | // partial, so we need to buffer them 13 | 14 | buffer += chunk 15 | 16 | while (true) { 17 | const index = buffer.indexOf(delimiter) 18 | 19 | if (index === -1) { 20 | break 21 | } 22 | 23 | // Read chunk until \n\n 24 | const event = buffer.slice(0, index) 25 | controller.enqueue(event) 26 | 27 | // Reset buffer for the next event 28 | buffer = buffer.slice(index + delimiter.length) 29 | } 30 | }, 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/sse-json-decoder-stream.ts: -------------------------------------------------------------------------------- 1 | export class AnthropicSSEJSONDecoderStream extends TransformStream { 2 | constructor() { 3 | super({ 4 | transform: (chunk: string, controller) => { 5 | const lines = chunk.split('\n') 6 | 7 | for (const line of lines) { 8 | if (line === '') { 9 | continue 10 | } 11 | 12 | const colonIndex = line.indexOf(':') 13 | const key = line.slice(0, colonIndex) 14 | const value = line.slice(colonIndex + 1).trim() 15 | 16 | if (key === 'event') { 17 | // We ignore events 18 | continue 19 | } 20 | 21 | if (key === 'data') { 22 | try { 23 | const json = JSON.parse(value) as T 24 | controller.enqueue(json) 25 | continue 26 | } catch (error) { 27 | throw new Error('Error parsing JSON: ' + value + ' ' + error) 28 | } 29 | } 30 | 31 | console.warn('Unknown key:', { key, value }) 32 | } 33 | }, 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/text-decoder-stream.ts: -------------------------------------------------------------------------------- 1 | // Shim taken from: 2 | // https://developer.mozilla.org/en-US/docs/Web/API/TransformStream 3 | 4 | const tds: any = { 5 | start() { 6 | this.decoder = new TextDecoder(this.encoding) 7 | }, 8 | transform(chunk: any, controller: any) { 9 | controller.enqueue(this.decoder.decode(chunk, { stream: true })) 10 | }, 11 | } 12 | 13 | const _jstds_wm = new WeakMap() /* info holder */ 14 | 15 | export class TextDecoderStream extends TransformStream { 16 | constructor(encoding = 'utf-8', { ...options } = {}) { 17 | const t = { ...tds, encoding, options } 18 | 19 | super(t) 20 | _jstds_wm.set(this, t) 21 | } 22 | 23 | get encoding() { 24 | return _jstds_wm.get(this).decoder.encoding 25 | } 26 | 27 | get fatal() { 28 | return _jstds_wm.get(this).decoder.fatal 29 | } 30 | 31 | get ignoreBOM() { 32 | return _jstds_wm.get(this).decoder.ignoreBOM 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/text-encoder-stream.ts: -------------------------------------------------------------------------------- 1 | // Shim taken from: 2 | // https://developer.mozilla.org/en-US/docs/Web/API/TransformStream 3 | 4 | const tes: any = { 5 | start() { 6 | this.encoder = new TextEncoder() 7 | }, 8 | transform(chunk: any, controller: any) { 9 | controller.enqueue(this.encoder.encode(chunk)) 10 | }, 11 | } 12 | 13 | const _jstes_wm = new WeakMap() /* info holder */ 14 | 15 | export class TextEncoderStream extends TransformStream { 16 | constructor() { 17 | const transform = { ...tes } 18 | 19 | super(transform) 20 | _jstes_wm.set(this, transform) 21 | } 22 | 23 | get encoding() { 24 | return _jstes_wm.get(this).encoder.encoding 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/api/courses/outline/helpers/types.ts: -------------------------------------------------------------------------------- 1 | import { ChatMessageContent } from '@/server/lib/anthropic/types' 2 | 3 | export type PredictResponse = 4 | | { 5 | type: 'message_start' 6 | message: { 7 | id: string 8 | type: 'message' 9 | role: 'assistant' 10 | content: string | ChatMessageContent[] 11 | model: string 12 | stop_reason: string | null 13 | stop_sequence: string | null 14 | usage: { 15 | input_tokens: number 16 | output_tokens: number 17 | } 18 | } 19 | } 20 | | { 21 | type: 'content_block_start' 22 | index: number 23 | content_block: { 24 | type: 'text' 25 | text: string 26 | } 27 | } 28 | | { 29 | type: 'ping' 30 | } 31 | | { 32 | type: 'content_block_delta' 33 | index: number 34 | delta: { 35 | type: 'text_delta' 36 | text: string 37 | } 38 | } 39 | | { 40 | type: 'content_block_stop' 41 | index: number 42 | } 43 | | { 44 | type: 'message_delta' 45 | delta: { 46 | stop_reason: string 47 | stop_sequence: null 48 | } 49 | usage: { 50 | output_tokens: number 51 | } 52 | } 53 | | { 54 | type: 'message_stop' 55 | } 56 | -------------------------------------------------------------------------------- /app/api/courses/outline/route.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertString } from '@/lib/assert' 2 | import { generateCoursePrompt } from '@/server/helpers/ai/prompts/generate-course' 3 | import { withAuth } from '@/server/helpers/auth' 4 | 5 | import { AnthropicPredictDecoderStream } from './helpers/predict-decoder-stream' 6 | import { SSEDecoderStream } from './helpers/sse-decoder-stream' 7 | import { AnthropicSSEJSONDecoderStream } from './helpers/sse-json-decoder-stream' 8 | import { TextDecoderStream } from './helpers/text-decoder-stream' 9 | import { PredictResponse } from './helpers/types' 10 | import { getStreamingPredictedMessages } from '@/server/lib/anthropic/completion' 11 | 12 | export const runtime = 'edge' 13 | 14 | async function generateOutline(req: Request) { 15 | const { description, weekCount = 13, language = 'English' } = await req.json() 16 | 17 | assertString(description, 'description must be a string') 18 | 19 | const responseMessages = generateCoursePrompt(description, { weekCount, language }) 20 | 21 | const response = await getStreamingPredictedMessages({ 22 | messages: responseMessages, 23 | }) 24 | 25 | if (!response.ok) { 26 | throw new Error('Opus API error: ' + (await response.text())) 27 | } 28 | 29 | assert(response.body, 'response.body must be defined') 30 | 31 | const stream = response.body 32 | .pipeThrough(new TextDecoderStream()) 33 | .pipeThrough(new SSEDecoderStream()) 34 | .pipeThrough(new AnthropicSSEJSONDecoderStream()) 35 | .pipeThrough(new AnthropicPredictDecoderStream()) 36 | .pipeThrough(new TextEncoderStream()) 37 | 38 | return new Response(stream, { 39 | headers: { 40 | 'Content-Type': 'text/plain', 41 | 'Cache-Control': 'no-cache, no-transform', 42 | }, 43 | }) 44 | } 45 | 46 | export const POST = withAuth(generateOutline) 47 | -------------------------------------------------------------------------------- /app/api/courses/route.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | import { z } from 'zod' 3 | 4 | import { slugify } from '@/lib/slugify' 5 | import { generateUniqueCourseSlug } from '@/server/db/courses/getters' 6 | import { createCourse } from '@/server/db/courses/setters' 7 | import { withApiBuilder } from '@/server/helpers/api-builder' 8 | import { withAuth } from '@/server/helpers/auth' 9 | import { inngest } from '@/server/jobs/client' 10 | import { NextResponse } from 'next/server' 11 | 12 | const ApiSchema = z.object({ 13 | title: z.string().min(2).max(100), 14 | language: z.string().optional().default('English'), 15 | weekCount: z.coerce.number().default(4), 16 | description: z.string().min(10).max(5000), 17 | content: z.string().min(10).max(5000), 18 | }) 19 | 20 | type ApiRequestParams = z.infer 21 | 22 | export const POST = withAuth( 23 | withApiBuilder( 24 | ApiSchema, 25 | async (request, { data, userId }) => { 26 | const { title, description, content, language, weekCount } = data 27 | 28 | const slug = await generateUniqueCourseSlug(slugify(title)) 29 | 30 | const courseId = await createCourse({ 31 | ownerId: userId, 32 | title, 33 | language, 34 | weekCount, 35 | description, 36 | content, 37 | slug, 38 | }) 39 | 40 | await inngest.send({ 41 | id: `course-generate-${courseId}`, 42 | name: 'course/generate', 43 | data: { 44 | courseId, 45 | }, 46 | }) 47 | 48 | return NextResponse.json({ id: courseId }) 49 | }, 50 | ), 51 | ) 52 | -------------------------------------------------------------------------------- /app/api/redirect/courses/[courseId]/first-unit/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { getCourse, getFirstCourseUnit } from '@/server/db/courses/getters' 4 | import { notFound } from '@/server/helpers/error' 5 | import { getPathForCourseUnit } from '@/server/helpers/links' 6 | 7 | export async function GET( 8 | request: Request, 9 | { params }: { params: { courseId: string } }, 10 | ) { 11 | const course = await getCourse(params.courseId) 12 | const courseUnit = await getFirstCourseUnit(params.courseId) 13 | 14 | if (!course || !courseUnit) { 15 | return notFound() 16 | } 17 | 18 | const url = new URL( 19 | getPathForCourseUnit({ 20 | course, 21 | courseModule: { 22 | number: courseUnit.moduleNumber, 23 | title: courseUnit.moduleTitle, 24 | }, 25 | courseUnit, 26 | }), 27 | request.url, 28 | ) 29 | 30 | return NextResponse.redirect(url) 31 | } 32 | -------------------------------------------------------------------------------- /app/api/redirect/courses/[courseId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { getCourse } from '@/server/db/courses/getters' 4 | import { notFound } from '@/server/helpers/error' 5 | import { getPathForCourse } from '@/server/helpers/links' 6 | 7 | export async function GET( 8 | request: Request, 9 | { params }: { params: { courseId: string } }, 10 | ) { 11 | const course = await getCourse(params.courseId) 12 | 13 | if (!course) { 14 | return notFound() 15 | } 16 | 17 | const url = new URL(getPathForCourse({ course }), request.url) 18 | 19 | return NextResponse.redirect(url) 20 | } 21 | -------------------------------------------------------------------------------- /app/api/redirect/units/[unitId]/next/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { getCourse } from '@/server/db/courses/getters' 4 | import { getModule } from '@/server/db/modules/getters' 5 | import { getNextUnit } from '@/server/db/units/getters' 6 | import { notFound } from '@/server/helpers/error' 7 | import { getPathForCourseUnit } from '@/server/helpers/links' 8 | 9 | export async function GET(request: Request, { params }: { params: { unitId: string } }) { 10 | const nextCourseUnit = await getNextUnit(params.unitId) 11 | 12 | if (!nextCourseUnit) { 13 | // Redirect back 14 | return NextResponse.redirect(request.headers.get('referer') || '/') 15 | } 16 | 17 | const courseModule = await getModule(nextCourseUnit.moduleId) 18 | 19 | if (!courseModule) { 20 | return notFound() 21 | } 22 | 23 | const course = await getCourse(courseModule.courseId) 24 | 25 | if (!course) { 26 | return notFound() 27 | } 28 | 29 | const url = new URL( 30 | getPathForCourseUnit({ course, courseModule, courseUnit: nextCourseUnit }), 31 | request.url, 32 | ) 33 | 34 | return NextResponse.redirect(url) 35 | } 36 | -------------------------------------------------------------------------------- /app/api/redirect/units/[unitId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { getCourse } from '@/server/db/courses/getters' 4 | import { getModule } from '@/server/db/modules/getters' 5 | import { getUnit } from '@/server/db/units/getters' 6 | import { notFound } from '@/server/helpers/error' 7 | import { getPathForCourseUnit } from '@/server/helpers/links' 8 | 9 | export async function GET(request: Request, { params }: { params: { unitId: string } }) { 10 | const courseUnit = await getUnit(params.unitId) 11 | 12 | if (!courseUnit) { 13 | return notFound() 14 | } 15 | 16 | const courseModule = await getModule(courseUnit.moduleId) 17 | 18 | if (!courseModule) { 19 | return notFound() 20 | } 21 | 22 | const course = await getCourse(courseModule.courseId) 23 | 24 | if (!course) { 25 | return notFound() 26 | } 27 | 28 | const url = new URL( 29 | getPathForCourseUnit({ course, courseModule, courseUnit }), 30 | request.url, 31 | ) 32 | 33 | return NextResponse.redirect(url) 34 | } 35 | -------------------------------------------------------------------------------- /app/api/units/[unitId]/complete/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { enrollInCourse, markUnitAsComplete } from '@/server/db/enrollment/setters' 4 | import { getUnitAndModule } from '@/server/db/units/getters' 5 | import { withAuth } from '@/server/helpers/auth' 6 | import { error } from '@/server/helpers/error' 7 | 8 | async function completeUnit( 9 | request: Request, 10 | { userId, params }: { userId: string; params: { unitId: string } }, 11 | ) { 12 | const unit = await getUnitAndModule(params.unitId) 13 | 14 | if (!unit) { 15 | return error('Unit not found') 16 | } 17 | 18 | // Noops if already enrolled 19 | await enrollInCourse({ userId, courseId: unit.courseId }) 20 | 21 | await markUnitAsComplete({ userId, unitId: unit.id, courseId: unit.courseId }) 22 | 23 | return NextResponse.json({ success: true }) 24 | } 25 | 26 | export const POST = withAuth(completeUnit) 27 | -------------------------------------------------------------------------------- /app/api/units/[unitId]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | import { z } from 'zod' 3 | 4 | import { getUnitAndCourse } from '@/server/db/units/getters' 5 | import { updateUnit } from '@/server/db/units/setters' 6 | import { withApiBuilder } from '@/server/helpers/api-builder' 7 | import { withAuth } from '@/server/helpers/auth' 8 | import { error } from '@/server/helpers/error' 9 | 10 | const ApiSchema = z.object({ 11 | unitId: z.string().uuid(), 12 | title: z.string().min(2).max(100), 13 | content: z.string().nonempty(), 14 | }) 15 | 16 | type ApiRequestParams = z.infer 17 | 18 | export const PUT = withAuth( 19 | withApiBuilder( 20 | ApiSchema, 21 | async (request, { data, userId }) => { 22 | const courseUnit = await getUnitAndCourse(data.unitId) 23 | 24 | if (!courseUnit) { 25 | return error('Unit not found', 'not_found', 404) 26 | } 27 | 28 | if (courseUnit.courseOwnerId !== userId) { 29 | return error('Unauthorized', 'unauthorized', 403) 30 | } 31 | 32 | await updateUnit(courseUnit.id, { 33 | title: data.title, 34 | content: data.content, 35 | }) 36 | 37 | return NextResponse.json({ success: true }) 38 | }, 39 | ), 40 | ) 41 | -------------------------------------------------------------------------------- /app/api/users/me/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | import { setUser } from '@/server/db/users/setters' 4 | import { withAuth } from '@/server/helpers/auth' 5 | import { error } from '@/server/helpers/error' 6 | 7 | async function updateCurrentUser(request: Request, { userId }: { userId: string }) { 8 | const body = await request.json() 9 | 10 | const name = body.name 11 | 12 | if (!name) { 13 | return error('Name is required') 14 | } 15 | 16 | await setUser(userId, { name }) 17 | 18 | return NextResponse.json({ success: true }) 19 | } 20 | 21 | export const PUT = withAuth(updateCurrentUser) 22 | -------------------------------------------------------------------------------- /app/auth/complete/page.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { redirect } from 'next/navigation' 4 | 5 | import { setUserAuth } from '@/server/db/users/setters' 6 | import { getSessionEmails, authOrRedirect } from '@/server/helpers/auth' 7 | import { baseUrl } from '@/server/helpers/base-url' 8 | 9 | export default async function AuthCompletePage({ 10 | searchParams, 11 | }: { 12 | searchParams: { redirect?: string } 13 | }) { 14 | const userId = await authOrRedirect() 15 | const emails = (await getSessionEmails()) ?? [] 16 | 17 | await setUserAuth({ 18 | id: userId, 19 | signInDate: new Date(), 20 | emails, 21 | }) 22 | 23 | // If redirect is valid and scoped to baseUrl, redirect to it 24 | if (searchParams.redirect && searchParams.redirect.startsWith(baseUrl)) { 25 | return redirect(searchParams.redirect) 26 | } 27 | 28 | redirect('/account') 29 | } 30 | -------------------------------------------------------------------------------- /app/auth/components/account-auth-dynamic.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import dynamic from 'next/dynamic' 4 | 5 | export const AccountAuth = dynamic(() => import('./account-auth'), { ssr: false }) 6 | -------------------------------------------------------------------------------- /app/auth/components/account-auth.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Hanko, register } from '@teamhanko/hanko-elements' 4 | import { useRouter } from 'next/navigation' 5 | import { useCallback, useEffect } from 'react' 6 | 7 | import { assertString } from '@/lib/assert' 8 | 9 | const hankoApiUrl = process.env.NEXT_PUBLIC_HANKO_API_URL 10 | 11 | export default function AccountAuth({ redirect = '' }: { redirect?: string }) { 12 | assertString(hankoApiUrl, 'Hanko API URL is not defined.') 13 | const router = useRouter() 14 | 15 | const redirectAfterLogin = useCallback(() => { 16 | const url = new URL('/auth/complete', window.location.href) 17 | 18 | if (redirect) { 19 | url.searchParams.set('redirect', redirect === 'back' ? document.referrer : redirect) 20 | } 21 | 22 | router.replace(url.toString()) 23 | }, [router, redirect]) 24 | 25 | useEffect(() => { 26 | const hanko = new Hanko(hankoApiUrl) 27 | hanko.onAuthFlowCompleted(() => { 28 | redirectAfterLogin() 29 | }) 30 | }, [redirectAfterLogin]) 31 | 32 | useEffect(() => { 33 | // register the component 34 | // see: https://github.com/teamhanko/hanko/blob/main/frontend/elements/README.md#script 35 | register(hankoApiUrl) 36 | }, []) 37 | 38 | return 39 | } 40 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | 3 | import { HeaderLayout } from '@/components/layouts/header-layout' 4 | import { getCourseBySlugOrId } from '@/server/db/courses/getters' 5 | 6 | import { notFound } from 'next/navigation' 7 | 8 | export default async function CourseShowLayout({ 9 | children, 10 | params, 11 | }: { 12 | children: ReactNode 13 | params: { courseSlug: string } 14 | }) { 15 | const course = await getCourseBySlugOrId(params.courseSlug) 16 | 17 | if (!course) { 18 | console.warn(`Course with slug "${params.courseSlug}" not found`) 19 | return notFound() 20 | } 21 | 22 | return {children} 23 | } 24 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/modules/[moduleSlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from 'next/navigation' 2 | 3 | import { getFirstCourseUnit } from '@/server/db/courses/getters' 4 | import { getPathForCourseUnit } from '@/server/helpers/links' 5 | import { getCourseContext } from '@/server/helpers/params-getters' 6 | 7 | export default async function CourseModulePage({ 8 | params, 9 | }: { 10 | params: { courseSlug: string; moduleSlug: string } 11 | }) { 12 | const { course, courseModule } = await getCourseContext(params) 13 | 14 | if (!course || !courseModule) { 15 | notFound() 16 | } 17 | 18 | const courseUnit = await getFirstCourseUnit(course.id) 19 | 20 | if (!courseUnit) { 21 | notFound() 22 | } 23 | 24 | const path = getPathForCourseUnit({ 25 | course, 26 | courseModule, 27 | courseUnit, 28 | }) 29 | 30 | redirect(path) 31 | } 32 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/modules/[moduleSlug]/units/[unitSlug]/edit/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function CourseUnitEditLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode 5 | }) { 6 | return
{children}
7 | } 8 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/modules/[moduleSlug]/units/[unitSlug]/edit/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation' 2 | 3 | import { authOrRedirect } from '@/server/helpers/auth' 4 | import { getCourseContext } from '@/server/helpers/params-getters' 5 | 6 | import { EditUnitForm } from './components/edit-unit-form' 7 | import CourseSidebarLayout from '@/components/layouts/course-sidebar-layout' 8 | 9 | export default async function AdminCourseUnitEdit({ 10 | params, 11 | }: { 12 | params: { courseSlug: string; moduleSlug: string; unitSlug: string } 13 | }) { 14 | const { course, courseUnit } = await getCourseContext(params) 15 | const userId = await authOrRedirect() 16 | 17 | if (!courseUnit) { 18 | return notFound() 19 | } 20 | 21 | if (course.ownerId !== userId) { 22 | console.warn(`User ${userId} is not the owner of course ${course.id}.`) 23 | return notFound() 24 | } 25 | 26 | return ( 27 | 28 |
29 |

Edit unit

30 | 31 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/modules/[moduleSlug]/units/[unitSlug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { CourseUnitSkeleton } from '@/components/course-units/course-unit-skeleton' 2 | 3 | export default function CourseModuleUnitLoadingPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/modules/[moduleSlug]/units/[unitSlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | import { notFound } from 'next/navigation' 3 | 4 | import { CourseUnit } from '@/components/course-units/course-unit' 5 | import { titlize } from '@/lib/titlize' 6 | import { getCourseContext } from '@/server/helpers/params-getters' 7 | import CourseSidebarLayout from '@/components/layouts/course-sidebar-layout' 8 | 9 | export async function generateMetadata({ 10 | params, 11 | }: { 12 | params: { courseSlug: string; moduleSlug: string; unitSlug: string } 13 | }): Promise { 14 | const { course, courseModule, courseUnit } = await getCourseContext(params) 15 | 16 | if (!course || !courseModule || !courseUnit) { 17 | return { 18 | title: '101.school', 19 | description: 'Teach yourself anything', 20 | } 21 | } 22 | 23 | return { 24 | title: `${courseModule.title} / ${courseUnit.title}`, 25 | description: course.description, 26 | openGraph: { 27 | title: `${course.title} - 101.school`, 28 | description: course.description, 29 | images: courseUnit?.image 30 | ? [ 31 | { 32 | url: courseUnit.image.source, 33 | alt: titlize(courseUnit.image.description ?? courseUnit.title), 34 | }, 35 | ] 36 | : [], 37 | }, 38 | twitter: { 39 | title: `${course.title} - 101.school`, 40 | description: course.description, 41 | card: 'summary_large_image', 42 | images: courseUnit?.image ? [courseUnit.image.source] : [], 43 | }, 44 | } 45 | } 46 | 47 | export default async function CourseModuleUnitPage({ 48 | params, 49 | }: { 50 | params: { courseSlug: string; moduleSlug: string; unitSlug: string } 51 | }) { 52 | const { course, courseModule, courseUnit } = await getCourseContext(params) 53 | 54 | if (!course || !courseModule || !courseUnit) { 55 | return notFound() 56 | } 57 | 58 | return ( 59 | 60 | 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation' 2 | 3 | import { CourseUnit } from '@/components/course-units/course-unit' 4 | import { getCourseBySlugOrId, getFirstCourseUnit } from '@/server/db/courses/getters' 5 | import { getModule } from '@/server/db/modules/getters' 6 | import CourseSidebarLayout from '@/components/layouts/course-sidebar-layout' 7 | import { CourseGenerating } from '@/components/courses/course-generating' 8 | 9 | export default async function CoursePage({ params }: { params: { courseSlug: string } }) { 10 | const course = await getCourseBySlugOrId(params.courseSlug) 11 | 12 | if (!course) { 13 | return notFound() 14 | } 15 | 16 | const courseUnit = await getFirstCourseUnit(course.id) 17 | 18 | if (!courseUnit) { 19 | console.error( 20 | `Course with slug "${params.courseSlug}" does not have a first module and unit`, 21 | ) 22 | return 23 | } 24 | 25 | const courseModule = await getModule(courseUnit.moduleId) 26 | 27 | if (!courseModule) { 28 | console.error(`Course with slug "${params.courseSlug}" has no module`) 29 | return notFound() 30 | } 31 | 32 | return ( 33 | 34 | 35 | 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/courses/[courseSlug]/unsubscribe/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation' 2 | 3 | import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 4 | import { getCourseBySlugOrId } from '@/server/db/courses/getters' 5 | 6 | import { CourseUnsubscribeForm } from './components/course-unsubscribe-form' 7 | import CourseSidebarLayout from '@/components/layouts/course-sidebar-layout' 8 | 9 | export default async function CourseUnsubscribePage({ 10 | params, 11 | searchParams, 12 | }: { 13 | params: { courseSlug: string } 14 | searchParams?: { email?: string } 15 | }) { 16 | const course = await getCourseBySlugOrId(params.courseSlug) 17 | 18 | if (!course) { 19 | return notFound() 20 | } 21 | 22 | return ( 23 | 24 |
25 | 26 | 27 | Unsubscribe from course 28 | 29 | 33 | 34 | 35 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /app/courses/category/[categorySlug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { CoursesLayout } from '../../components/courses-layout' 2 | 3 | export default async function CoursesCategoryPage({ 4 | params, 5 | }: { 6 | params: { categorySlug: string } 7 | }) { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /app/courses/components/courses-grid.tsx: -------------------------------------------------------------------------------- 1 | import { CourseCard } from '@/components/courses/course-card' 2 | import { 3 | getFeaturedCourses, 4 | getFeaturedCoursesByCategorySlug, 5 | } from '@/server/db/courses/getters' 6 | 7 | interface CoursesGrid { 8 | categorySlug?: string 9 | } 10 | 11 | export async function CoursesGrid({ categorySlug }: CoursesGrid) { 12 | const courses = categorySlug 13 | ? await getFeaturedCoursesByCategorySlug(categorySlug) 14 | : await getFeaturedCourses() 15 | 16 | return ( 17 |
18 | {courses.map((course) => ( 19 | 20 | ))} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/courses/components/courses-layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { Suspense } from 'react' 3 | 4 | import { HeaderLayout } from '@/components/layouts/header-layout' 5 | import { buttonVariants } from '@/components/ui/button' 6 | import { Separator } from '@/components/ui/separator' 7 | import { cn } from '@/lib/utils' 8 | 9 | import { CoursesGrid } from './courses-grid' 10 | import { CoursesNav } from './courses-nav' 11 | import { CoursesSkeletonGrid } from './courses-skeleton-grid' 12 | 13 | interface CoursesLayout { 14 | categorySlug?: string 15 | } 16 | 17 | export async function CoursesLayout({ categorySlug }: CoursesLayout) { 18 | return ( 19 | 20 |
21 |
22 |

23 | What would you like to teach yourself? 24 |

25 |

26 | AI generated courses based on your interests. 27 |

28 |
29 | 30 | 31 | 32 |
33 | 45 | 46 |
47 | }> 48 | 49 | 50 |
51 |
52 |
53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/courses/components/courses-nav.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import Link from 'next/link' 4 | import { usePathname } from 'next/navigation' 5 | 6 | import { buttonVariants } from '@/components/ui/button' 7 | import { CIP_CATEGORIES } from '@/lib/cip-category' 8 | import { cn } from '@/lib/utils' 9 | 10 | const categoryLinks = CIP_CATEGORIES.map((category) => ({ 11 | href: `/courses/category/${category.slug}`, 12 | title: category.title, 13 | })) 14 | 15 | const links = [ 16 | { 17 | href: '/courses', 18 | title: 'Featured Courses', 19 | }, 20 | ...categoryLinks, 21 | ] 22 | 23 | export function CoursesNav() { 24 | let pathname = usePathname() 25 | 26 | if (pathname === '/') { 27 | pathname = '/courses' 28 | } 29 | 30 | return ( 31 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/courses/components/courses-skeleton-grid.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton' 2 | 3 | export async function CoursesSkeletonGrid() { 4 | return ( 5 |
6 | {Array.from({ length: 12 }).map((_, index) => ( 7 |
8 | 9 | 10 |
11 | ))} 12 |
13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/courses/new/components/confirm-outline-form.tsx: -------------------------------------------------------------------------------- 1 | import { UseFormReturn } from 'react-hook-form' 2 | 3 | import { ScrollingTextarea } from '@/components/scrolling-textarea' 4 | import { Button } from '@/components/ui/button' 5 | import { 6 | Form, 7 | FormControl, 8 | FormField, 9 | FormItem, 10 | FormLabel, 11 | FormMessage, 12 | } from '@/components/ui/form' 13 | 14 | import { ConfirmOutlineFormValues } from './types' 15 | 16 | interface ConfirmOutlineFormProps { 17 | isPending: boolean 18 | onSubmit: (values: ConfirmOutlineFormValues) => void 19 | form: UseFormReturn 20 | } 21 | 22 | export function ConfirmOutlineForm({ 23 | form, 24 | isPending, 25 | onSubmit, 26 | }: ConfirmOutlineFormProps) { 27 | const { isValid } = form.formState 28 | 29 | const isPendingOrInvalid = !isValid || isPending 30 | 31 | function handleSubmit(values: ConfirmOutlineFormValues) { 32 | if (isPendingOrInvalid) { 33 | return 34 | } 35 | 36 | onSubmit(values) 37 | } 38 | 39 | return ( 40 |
41 | 42 | ( 46 | 47 | Generated content 48 | 49 | 55 | 56 | 57 | 58 | )} 59 | /> 60 | 61 |
62 | 65 |
66 | 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /app/courses/new/components/types.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const generateOutlineFormSchema = z.object({ 4 | title: z.string().min(2).max(100), 5 | description: z.string().min(10).max(5000), 6 | weekCount: z.coerce.number().int().min(1).max(13), 7 | language: z.string(), 8 | }) 9 | 10 | export type GenerateOutlineFormValues = z.infer 11 | 12 | export const confirmOutlineFormSchema = z.object({ 13 | content: z.string().min(10).max(5000), 14 | }) 15 | 16 | export type ConfirmOutlineFormValues = z.infer 17 | -------------------------------------------------------------------------------- /app/courses/new/components/user-manual.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/lib/utils' 2 | 3 | interface UserManualProps extends React.HTMLAttributes {} 4 | 5 | export function UserManual({ className, ...props }: UserManualProps) { 6 | return ( 7 |
14 |

User manual

15 | 16 |

17 | We use the course description to generate a course outline. A description can be 18 | brief, such as '101 cooking school', or more detailed, such as{' '} 19 | 'Learn how to cook from scratch. Covering xyz areas'. 20 |

21 | 22 |

23 | We use GPT-4 to generate the actual course. The advantage of this, of course, is 24 | speed - it takes roughly five minutes to generate a comprenenshive 13 week course. 25 | The disadvantage is hallucinations. 26 |

27 | 28 |

29 | While in practice we have found hallucinations to be rare, they're always 30 | possible. Especially if you ask for a course on a topic that GPT-4 doesn't 31 | have enough data on. The worst is when you ask for a course about a book that 32 | GPT-4 knows the chapter titles of, but not the contents. Total gibberish. 33 |

34 | 35 |

36 | Outside of those caveats we do most text-book material well. Practical courses are 37 | more of hit and miss. You'll just have to try it out and see. 38 |

39 | 40 |

p.s. generated courses, for now, will be public so please keep them SFW.

41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/courses/new/components/utils.ts: -------------------------------------------------------------------------------- 1 | export function stripTripleBackticks(text: string) { 2 | // Strip ```js\n and ```\n from the first and last line 3 | return text.replace(/^```.*?\n/, '').replace(/\n```$/, '') 4 | } 5 | -------------------------------------------------------------------------------- /app/courses/new/page.tsx: -------------------------------------------------------------------------------- 1 | import { HeaderLayout } from '@/components/layouts/header-layout' 2 | import { 3 | Card, 4 | CardHeader, 5 | CardTitle, 6 | CardDescription, 7 | CardContent, 8 | } from '@/components/ui/card' 9 | import { authOrRedirect } from '@/server/helpers/auth' 10 | 11 | import { NewCourseManager } from './components/new-course-manager' 12 | import { UserManual } from './components/user-manual' 13 | 14 | export default async function NewCoursePage() { 15 | await authOrRedirect() 16 | 17 | return ( 18 | 19 |
20 | 21 | 22 | 23 | 24 | Create a new course 25 | 26 | Describe your course and AI will do the rest. 27 | 28 | 29 | 30 | 31 | 32 | 33 |
34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /app/courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { CoursesLayout } from './components/courses-layout' 2 | 3 | export default async function CoursesPage() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maccman/101-school/c02d8b1027c9146b1f349ac6e19071ef83b8f16a/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | 3 | import { Analytics } from '@vercel/analytics/react' 4 | import { Metadata } from 'next' 5 | import { Inter } from 'next/font/google' 6 | 7 | import { ThemeProvider } from '@/components/theme-provider' 8 | import { Toaster } from '@/components/ui/toaster' 9 | import { baseUrl } from '@/server/helpers/base-url' 10 | 11 | export const metadata: Metadata = { 12 | metadataBase: new URL(baseUrl), 13 | title: '101.school', 14 | description: 'Teach yourself anything.', 15 | icons: [`${baseUrl}/static/logo.png`], 16 | twitter: { 17 | title: '101.school', 18 | description: 'Teach yourself anything.', 19 | card: 'summary', 20 | images: [`${baseUrl}/static/twitter.png`], 21 | }, 22 | } 23 | 24 | export const runtime = 'edge' 25 | export const preferredRegion = 'iad1' 26 | 27 | // Vercel caching does some weird things 28 | export const fetchCache = 'force-no-store' 29 | 30 | const inter = Inter({ subsets: ['latin'] }) 31 | 32 | export default function RootLayout({ children }: { children: React.ReactNode }) { 33 | return ( 34 | 35 | 38 | 39 | {children} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import CoursesPage from './courses/page' 2 | 3 | export default function Home() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /app/payments/failure/components/try-again-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { buttonVariants } from '@/components/ui/button' 4 | import { cn } from '@/lib/utils' 5 | import Link from 'next/link' 6 | import { useSearchParams } from 'next/navigation' 7 | 8 | export function TryAgainButton() { 9 | const searchParams = useSearchParams() 10 | const courseId = searchParams?.get('courseId') 11 | 12 | if (!courseId) { 13 | return null 14 | } 15 | 16 | return ( 17 | 21 | Try again... 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /app/payments/failure/page.tsx: -------------------------------------------------------------------------------- 1 | import { XCircle } from 'lucide-react' 2 | 3 | import { HeaderLayout } from '@/components/layouts/header-layout' 4 | import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 5 | import { TryAgainButton } from './components/try-again-button' 6 | 7 | export default function PaymentsFailure() { 8 | return ( 9 | 10 |
11 | 12 | 13 | 14 | 15 | Payment failure 16 | 17 | 18 |

Payment failed.

19 | 20 | 21 |
22 |
23 |
24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/payments/success/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation' 2 | 3 | import { CourseGenerating } from '@/components/courses/course-generating' 4 | import { getCourse } from '@/server/db/courses/getters' 5 | 6 | export default async function PaymentSuccess({ 7 | searchParams, 8 | }: { 9 | searchParams?: { courseId: string } 10 | }) { 11 | const course = searchParams?.courseId ? await getCourse(searchParams.courseId) : null 12 | 13 | if (course?.generatedAt) { 14 | redirect(`/courses/${course.slug}`) 15 | } 16 | 17 | return 18 | } 19 | -------------------------------------------------------------------------------- /app/sign-out/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server' 2 | 3 | export async function GET() { 4 | const response = new NextResponse('', { 5 | status: 302, 6 | headers: { 7 | Location: '/', 8 | }, 9 | }) 10 | 11 | response.cookies.delete('hanko') 12 | 13 | return response 14 | } 15 | -------------------------------------------------------------------------------- /app/types.ts: -------------------------------------------------------------------------------- 1 | export interface SearchResult { 2 | type: 'course' | 'unit' 3 | id: string 4 | title: string 5 | path: string 6 | icon?: string 7 | image?: string 8 | } 9 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /components/chat-scroll-anchor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect } from 'react' 4 | import { useInView } from 'react-intersection-observer' 5 | 6 | interface ChatScrollAnchorProps { 7 | trackVisibility?: boolean 8 | } 9 | 10 | export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) { 11 | const { ref, entry, inView } = useInView({ 12 | trackVisibility, 13 | delay: 100, 14 | }) 15 | 16 | useEffect(() => { 17 | if (trackVisibility && !inView) { 18 | entry?.target.scrollIntoView({ 19 | block: 'start', 20 | }) 21 | } 22 | }, [inView, entry, trackVisibility]) 23 | 24 | return
25 | } 26 | -------------------------------------------------------------------------------- /components/chat-sidebar/chat-message-content.tsx: -------------------------------------------------------------------------------- 1 | import remarkGfm from 'remark-gfm' 2 | import remarkMath from 'remark-math' 3 | 4 | import { CodeBlock } from '@/components/ui/codeblock' 5 | import { MemoizedReactMarkdown } from '@/components/ui/markdown' 6 | 7 | export function ChatMessageContent({ content }: { content: string }) { 8 | return ( 9 | 16 | {children} 17 | 18 | ) 19 | }, 20 | p({ children }) { 21 | return

{children}

22 | }, 23 | code({ inline, className, children, ...props }) { 24 | if (children.length) { 25 | if (children[0] == '▍') { 26 | return 27 | } 28 | 29 | children[0] = (children[0] as string).replace('`▍`', '▍') 30 | } 31 | 32 | const match = /language-(\w+)/.exec(className || '') 33 | 34 | if (inline) { 35 | return ( 36 | 37 | {children} 38 | 39 | ) 40 | } 41 | 42 | return ( 43 | 49 | ) 50 | }, 51 | }} 52 | > 53 | {content} 54 |
55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/chat-sidebar/chat-message.tsx: -------------------------------------------------------------------------------- 1 | import { GraduationCap, User } from 'lucide-react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | import { ChatMessageContent } from './chat-message-content' 6 | import { Message } from './types' 7 | 8 | export function ChatMessage({ message }: { message: Message }) { 9 | const isAssistant = message.role === 'assistant' 10 | 11 | return ( 12 |
13 |
14 | {isAssistant ? ( 15 | 16 | ) : ( 17 | 18 | )} 19 |
20 | 21 |
22 | 23 | 24 | {isAssistant ? : } 25 |
26 |
27 | ) 28 | } 29 | 30 | function RightArrow() { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | function LeftArrow() { 37 | return ( 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/chat-sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './chat-sidebar' 2 | -------------------------------------------------------------------------------- /components/chat-sidebar/types.ts: -------------------------------------------------------------------------------- 1 | import { Message as BaseMessage } from 'ai/react' 2 | 3 | export interface Message extends BaseMessage {} 4 | -------------------------------------------------------------------------------- /components/chat-sidebar/utils.ts: -------------------------------------------------------------------------------- 1 | import { Message } from 'ai' 2 | 3 | import { assert } from '@/lib/assert' 4 | import { enumFromString } from '@/lib/enum' 5 | import { UnitChatMessage } from '@/server/db/unit_chats/types' 6 | import { nanoid } from '@/server/lib/id' 7 | 8 | enum MessageRole { 9 | system = 'system', 10 | user = 'user', 11 | assistant = 'assistant', 12 | function = 'function', 13 | } 14 | 15 | export function unitMessageToChatMessage(message: UnitChatMessage): Message { 16 | const role = enumFromString(MessageRole, message.role) 17 | assert(role, `Invalid message role: ${message.role}`) 18 | 19 | return { 20 | id: message.id ?? nanoid(), 21 | content: message.content ?? '', 22 | role, 23 | createdAt: message.createdAt ? new Date(message.createdAt) : undefined, 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /components/course-sidebar/course-progress.tsx: -------------------------------------------------------------------------------- 1 | import { CourseEnrollment } from '@/server/db/enrollment/types' 2 | 3 | import { Progress } from '../ui/progress' 4 | 5 | interface CourseProgressProps extends React.HTMLAttributes { 6 | courseEnrollment: CourseEnrollment 7 | } 8 | 9 | export function CourseProgress({ courseEnrollment, ...props }: CourseProgressProps) { 10 | if (courseEnrollment.completedUnitIds.length === 0) { 11 | return null 12 | } 13 | 14 | const totalCount = courseEnrollment.unitCount 15 | const completedCount = courseEnrollment.completedUnitIds.length 16 | 17 | const progress = Math.round((completedCount / totalCount) * 100) 18 | 19 | return ( 20 |
21 |

Your progress

22 | 23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/course-sidebar/course-sidebar-with-enrollment.tsx: -------------------------------------------------------------------------------- 1 | import { CourseSidebar } from '@/components/course-sidebar' 2 | import { Course, CourseUnits } from '@/server/db/courses/types' 3 | import { getCourseEnrollment } from '@/server/db/enrollment/getters' 4 | import { getUser } from '@/server/db/users/getters' 5 | import { auth } from '@/server/helpers/auth' 6 | 7 | interface CourseSidebarWithEnrollmentProps extends React.HTMLAttributes { 8 | course: Course 9 | courseUnits: CourseUnits 10 | } 11 | 12 | export default async function CourseSidebarWithEnrollment({ 13 | course, 14 | courseUnits, 15 | ...props 16 | }: CourseSidebarWithEnrollmentProps) { 17 | const userId = await auth() 18 | 19 | const [user, courseEnrollment] = await Promise.all([ 20 | userId ? getUser(userId) : null, 21 | userId ? getCourseEnrollment({ userId, courseId: course.id }) : null, 22 | ]) 23 | 24 | return ( 25 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /components/course-sidebar/index.ts: -------------------------------------------------------------------------------- 1 | export * from './course-sidebar' 2 | export * from './course-sidebar-with-enrollment' 3 | -------------------------------------------------------------------------------- /components/course-sidebar/unit-list-item.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle, CircleDashed } from 'lucide-react' 2 | import Link from 'next/link' 3 | import { usePathname } from 'next/navigation' 4 | 5 | import { cn } from '@/lib/utils' 6 | import { Course, CourseModule } from '@/server/db/courses/types' 7 | import { getPathForCourseUnit } from '@/server/helpers/links' 8 | 9 | import { Tooltip } from '../tooltip' 10 | import { buttonVariants } from '../ui/button' 11 | 12 | interface UnitListItemProps extends React.HTMLAttributes { 13 | course: Course 14 | courseModule: CourseModule 15 | courseUnit: { 16 | id: string 17 | title: string 18 | number: number 19 | } 20 | completed: boolean 21 | } 22 | 23 | export function UnitListItem({ 24 | course, 25 | courseModule, 26 | courseUnit, 27 | completed, 28 | ...props 29 | }: UnitListItemProps) { 30 | const pathname = usePathname() 31 | 32 | const unitPath = getPathForCourseUnit({ 33 | course, 34 | courseModule, 35 | courseUnit, 36 | }) 37 | 38 | return ( 39 |
  • 40 | 48 | 49 | {courseModule.number}.{courseUnit.number} 50 | 51 | {courseUnit.title} 52 | 53 |
    54 | {completed ? ( 55 | 56 | 57 | 58 | ) : ( 59 | 60 | 61 | 62 | )} 63 |
    64 | 65 |
  • 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /components/course-units/complete-button/complete-button-client.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { sample } from 'lodash' 4 | import { CheckCircle, CircleDashed } from 'lucide-react' 5 | import { useRouter } from 'next/navigation' 6 | import { useState } from 'react' 7 | 8 | import { Button } from '@/components/ui/button' 9 | import { cn } from '@/lib/utils' 10 | 11 | import { toast } from '../../ui/use-toast' 12 | 13 | interface CompleteButtonProps { 14 | unitId: string 15 | isCompleted: boolean 16 | isAuthenticated: boolean 17 | } 18 | 19 | const encouragingMessages = [ 20 | 'Keep up the good work!', 21 | 'Getting smarter!', 22 | 'On the path to a nobel prize!', 23 | 'You Einstein, you!', 24 | 'Ice cream yum', 25 | ] 26 | 27 | export function CompleteButtonClient({ 28 | unitId, 29 | isCompleted, 30 | isAuthenticated, 31 | }: CompleteButtonProps) { 32 | const router = useRouter() 33 | const [pending, setPending] = useState(false) 34 | 35 | async function handleClick() { 36 | if (!isAuthenticated) { 37 | return router.push('/auth') 38 | } 39 | 40 | if (pending) { 41 | return 42 | } 43 | 44 | setPending(true) 45 | 46 | toast({ 47 | title: 'Unit completed', 48 | description: sample(encouragingMessages), 49 | }) 50 | 51 | await fetchComplete({ unitId }) 52 | router.refresh() 53 | router.push(`/api/redirect/units/${unitId}/next`) 54 | } 55 | 56 | return isCompleted ? ( 57 | 61 | ) : ( 62 | 66 | ) 67 | } 68 | 69 | function fetchComplete({ unitId }: { unitId: string }) { 70 | return fetch(`/api/units/${unitId}/complete`, { 71 | method: 'POST', 72 | }) 73 | } 74 | -------------------------------------------------------------------------------- /components/course-units/complete-button/complete-button.tsx: -------------------------------------------------------------------------------- 1 | import { getCourseEnrollment } from '@/server/db/enrollment/getters' 2 | import { auth } from '@/server/helpers/auth' 3 | 4 | import { CompleteButtonClient } from './complete-button-client' 5 | 6 | interface CompleteButtonProps { 7 | courseId: string 8 | unitId: string 9 | } 10 | 11 | export async function CompleteButton({ courseId, unitId }: CompleteButtonProps) { 12 | const userId = await auth() 13 | const enrollment = userId ? await getCourseEnrollment({ userId, courseId }) : null 14 | const isCompleted = enrollment?.completedUnitIds?.includes(unitId) ?? false 15 | 16 | return ( 17 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /components/course-units/complete-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './complete-button' 2 | -------------------------------------------------------------------------------- /components/course-units/course-unit-skeleton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | 5 | import { Progress } from '../ui/progress' 6 | 7 | export function CourseUnitSkeleton() { 8 | const [value, setValue] = useState(10) 9 | 10 | useEffect(() => { 11 | setValue(90) 12 | }, []) 13 | 14 | return ( 15 |
    16 | 17 |
    18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/course-units/course-unit.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { CourseModuleUnit } from '@/server/db/units/types' 5 | 6 | import { EditButton } from './edit-button' 7 | import { UnitContent } from './unit-content' 8 | import { UnitFooter } from './unit-footer' 9 | import { ChatSidebar } from '../chat-sidebar' 10 | 11 | interface CourseUnitProps { 12 | course: { 13 | id: string 14 | slug: string 15 | ownerId: string 16 | } 17 | courseModule: { 18 | title: string 19 | number: number 20 | } 21 | courseUnit: CourseModuleUnit 22 | className?: string 23 | } 24 | 25 | export function CourseUnit({ 26 | courseModule, 27 | courseUnit, 28 | course, 29 | className, 30 | }: CourseUnitProps) { 31 | return ( 32 |
    33 |
    34 | 35 | 41 | 42 | 43 |

    44 | {courseModule.title} 45 |

    46 | 47 | {courseUnit.content && ( 48 | 49 | )} 50 | 51 | 52 | 53 | 54 |
    55 | 56 |
    57 | 58 | 59 | 60 |
    61 |
    62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /components/course-units/edit-button.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { cn } from '@/lib/utils' 4 | import { auth } from '@/server/helpers/auth' 5 | import { getPathForCourseUnit } from '@/server/helpers/links' 6 | 7 | import { buttonVariants } from '../ui/button' 8 | 9 | interface EditButtonProps { 10 | course: { 11 | slug: string 12 | ownerId: string 13 | } 14 | courseModule: { 15 | number: number 16 | title: string 17 | } 18 | courseUnit: { 19 | number: number 20 | title: string 21 | } 22 | className?: string 23 | } 24 | 25 | export async function EditButton({ 26 | course, 27 | courseModule, 28 | courseUnit, 29 | className, 30 | }: EditButtonProps) { 31 | const userId = await auth() 32 | 33 | if (!userId) { 34 | return null 35 | } 36 | 37 | if (course.ownerId !== userId) { 38 | return null 39 | } 40 | 41 | const linkHref = 42 | getPathForCourseUnit({ 43 | course, 44 | courseModule, 45 | courseUnit, 46 | }) + '/edit' 47 | 48 | return ( 49 | 53 | Edit unit 54 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/course-units/unit-footer.tsx: -------------------------------------------------------------------------------- 1 | import { CompleteButton } from './complete-button' 2 | import { UnitPagination } from './unit-pagination' 3 | 4 | interface UnitFooterProps { 5 | courseId: string 6 | unitId: string 7 | } 8 | 9 | export function UnitFooter({ courseId, unitId }: UnitFooterProps) { 10 | return ( 11 |
    12 | 13 | 14 |
    15 | 16 | 17 |
    18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/course-units/unit-image.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { titlize } from '@/lib/titlize' 6 | import { cn } from '@/lib/utils' 7 | 8 | interface Props extends React.HTMLAttributes { 9 | image: { 10 | source: string 11 | description: string | null 12 | } 13 | } 14 | 15 | export function UnitAsset({ image, className }: Props) { 16 | const [error, setError] = useState(false) 17 | const description = image.description ? titlize(image.description) : '' 18 | const isVideo = /\.(mp4|mov|ogv)$/.test(image.source) 19 | 20 | if (error) { 21 | return null 22 | } 23 | 24 | return ( 25 |
    window.open(image.source, '_blank', 'noopener noreferrer')} 31 | > 32 | {isVideo ? ( 33 |
    53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /components/course-units/unit-pagination.tsx: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import Link from 'next/link' 4 | 5 | import { getNextUnit, getUnitAndCourse } from '@/server/db/units/getters' 6 | import { getPathForCourseUnit } from '@/server/helpers/links' 7 | 8 | import { Button } from '../ui/button' 9 | 10 | export async function UnitPagination({ unitId }: { unitId: string }) { 11 | const nextUnit = await getNextUnit(unitId) 12 | 13 | if (!nextUnit) { 14 | return null 15 | } 16 | 17 | const nextUnitAndCourse = await getUnitAndCourse(nextUnit.id) 18 | 19 | if (!nextUnitAndCourse) { 20 | return null 21 | } 22 | 23 | return ( 24 | 34 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/courses/command-dialog/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command-dialog' 2 | -------------------------------------------------------------------------------- /components/courses/command-dialog/utils.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from '@/app/types' 2 | 3 | export interface FetchResultsOptions { 4 | courseId?: string 5 | query?: string 6 | signal?: AbortSignal 7 | } 8 | 9 | export async function fetchResults({ 10 | courseId, 11 | query = '', 12 | signal, 13 | }: FetchResultsOptions): Promise { 14 | const params = new URLSearchParams() 15 | 16 | if (courseId) { 17 | params.set('courseId', courseId) 18 | } 19 | 20 | if (query) { 21 | params.set('q', query) 22 | } 23 | 24 | const response = await fetch(`/api/search?${params.toString()}`, { 25 | signal, 26 | }) 27 | 28 | if (!response.ok) { 29 | throw new Error(response.statusText) 30 | } 31 | 32 | return response.json() 33 | } 34 | 35 | export function sortSearchResults(a: SearchResult, b: SearchResult) { 36 | if (a.type === 'course' && b.type === 'unit') { 37 | return 1 38 | } 39 | 40 | if (a.type === 'unit' && b.type === 'course') { 41 | return -1 42 | } 43 | 44 | return 0 45 | } 46 | -------------------------------------------------------------------------------- /components/courses/course-card.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Avatar, AvatarFallback, AvatarImage } from '@radix-ui/react-avatar' 4 | import Link from 'next/link' 5 | 6 | import { cn } from '@/lib/utils' 7 | 8 | import { Skeleton } from '../ui/skeleton' 9 | 10 | interface Props extends React.AnchorHTMLAttributes { 11 | course: { 12 | slug: string 13 | title: string 14 | headline: string 15 | description: string 16 | image: { source: string } | null 17 | } 18 | } 19 | 20 | export function CourseCard({ course, className, ...props }: Props) { 21 | const headline = course.headline || course.description 22 | 23 | return ( 24 | 29 |
    30 | 31 | 35 | 36 | 37 | 38 | 39 |
    40 | 41 |
    42 |

    {course.title}

    43 | {headline && ( 44 |

    45 | {headline} 46 |

    47 | )} 48 |
    49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /components/courses/course-generating.tsx: -------------------------------------------------------------------------------- 1 | import { CheckCircle2 } from 'lucide-react' 2 | 3 | import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' 4 | 5 | export function CourseGenerating() { 6 | return ( 7 |
    8 | 9 | 10 | 11 | 12 | Just one sec... 13 | 14 | 15 |

    16 | Your course is generating. You'll will receive an email once it's 17 | ready. 18 |

    19 |
    20 |
    21 |
    22 |
    23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/courses/enroll-button/enroll-button-client.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { CheckCircle, CircleDashed } from 'lucide-react' 4 | import { useRouter } from 'next/navigation' 5 | import { useState } from 'react' 6 | 7 | import { cn } from '@/lib/utils' 8 | 9 | import { Button, ButtonProps } from '../../ui/button' 10 | 11 | interface EnrollButtonProps extends ButtonProps { 12 | userId: string | null 13 | courseId: string 14 | enrolled: boolean 15 | } 16 | 17 | export function EnrollButtonClient({ 18 | courseId, 19 | enrolled, 20 | userId, 21 | ...props 22 | }: EnrollButtonProps) { 23 | const router = useRouter() 24 | const [pending, setPending] = useState(false) 25 | 26 | const handleClick = async () => { 27 | if (pending) { 28 | return 29 | } 30 | 31 | if (enrolled) { 32 | router.push('/account/courses') 33 | return 34 | } 35 | 36 | if (!userId) { 37 | router.push('/auth') 38 | return 39 | } 40 | 41 | setPending(true) 42 | 43 | await fetchEnroll(courseId) 44 | 45 | router.refresh() 46 | } 47 | 48 | return ( 49 | 62 | ) 63 | } 64 | 65 | function fetchEnroll(courseId: string) { 66 | return fetch(`/api/courses/${courseId}/enroll`, { 67 | method: 'POST', 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /components/courses/enroll-button/enroll-button.tsx: -------------------------------------------------------------------------------- 1 | import { getCourseEnrollment } from '@/server/db/enrollment/getters' 2 | import { auth } from '@/server/helpers/auth' 3 | 4 | import { EnrollButtonClient } from './enroll-button-client' 5 | import { ButtonProps } from '../../ui/button' 6 | 7 | interface EnrollButtonProps extends ButtonProps { 8 | courseId: string 9 | hideEnrolled?: boolean 10 | } 11 | 12 | export async function EnrollButton({ 13 | courseId, 14 | hideEnrolled, 15 | ...props 16 | }: EnrollButtonProps) { 17 | const userId = await auth() 18 | const courseEnrollment = userId ? await getCourseEnrollment({ userId, courseId }) : null 19 | 20 | if (hideEnrolled && courseEnrollment) { 21 | return null 22 | } 23 | 24 | return ( 25 | 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /components/courses/enroll-button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './enroll-button' 2 | -------------------------------------------------------------------------------- /components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ReactElement, ReactNode } from 'react' 4 | import { ErrorBoundary as ErrorBoundaryBase } from 'react-error-boundary' 5 | 6 | export function ErrorBoundary({ 7 | children, 8 | fallback =
    Something went wrong
    , 9 | }: { 10 | children: ReactNode 11 | fallback?: ReactElement 12 | }) { 13 | return {children} 14 | } 15 | -------------------------------------------------------------------------------- /components/header/index.ts: -------------------------------------------------------------------------------- 1 | export * from './header' 2 | -------------------------------------------------------------------------------- /components/header/search-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { triggerEvent } from '@/lib/use-event-listener' 4 | 5 | import { Button, ButtonProps } from '../ui/button' 6 | 7 | interface SearchButtonProps extends ButtonProps {} 8 | 9 | export function SearchButton({ ...props }: SearchButtonProps) { 10 | return ( 11 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/header/search-input.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SearchIcon } from 'lucide-react' 4 | 5 | import { triggerEvent } from '@/lib/use-event-listener' 6 | import { cn } from '@/lib/utils' 7 | 8 | interface SearchProps extends React.HTMLAttributes {} 9 | 10 | export function SearchInput({ className, ...props }: SearchProps) { 11 | return ( 12 |
    triggerEvent('command.dialog.open')} 19 | {...props} 20 | > 21 | 22 | Search... 23 | 24 | ⌘K 25 | 26 |
    27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /components/header/theme-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useTheme } from 'next-themes' 4 | import { SVGProps } from 'react' 5 | 6 | import { Button } from '../ui/button' 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from '../ui/dropdown-menu' 13 | 14 | export function ThemeButton() { 15 | const { setTheme, theme } = useTheme() 16 | 17 | return ( 18 | 19 | 20 | 32 | 33 | 34 | setTheme('light')}>Light 35 | setTheme('dark')}>Dark 36 | setTheme('system')}>System 37 | 38 | 39 | ) 40 | } 41 | 42 | function SunIcon(props: SVGProps) { 43 | return ( 44 | 51 | ) 52 | } 53 | 54 | function MoonIcon(props: SVGProps) { 55 | return ( 56 | 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /components/header/user-nav/user-nav.tsx: -------------------------------------------------------------------------------- 1 | import { getUser } from '@/server/db/users/getters' 2 | import { auth } from '@/server/helpers/auth' 3 | 4 | import { UserNavClient } from './user-nav-client' 5 | 6 | export async function UserNav() { 7 | const userId = await auth() 8 | const user = userId ? await getUser(userId) : null 9 | 10 | return ( 11 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/image-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState } from 'react' 4 | 5 | import { useEventListener } from '@/lib/use-event-listener' 6 | import { useKeyboardShortcut } from '@/lib/use-keyboard-shortcut' 7 | 8 | import { 9 | Dialog, 10 | DialogContent, 11 | DialogDescription, 12 | DialogHeader, 13 | DialogTitle, 14 | } from './ui/dialog' 15 | 16 | interface DialogImage { 17 | source: string 18 | alt?: string 19 | } 20 | 21 | export function previewImage(image: DialogImage) { 22 | window.dispatchEvent( 23 | new CustomEvent('image.dialog.open', { 24 | detail: image, 25 | }), 26 | ) 27 | } 28 | 29 | export function ImageDialog() { 30 | const [image, setImage] = useState(null) 31 | 32 | useKeyboardShortcut('Escape', () => setImage(null)) 33 | 34 | useEventListener('image.dialog.open', (event) => 35 | setImage({ 36 | source: event.detail.source, 37 | alt: event.detail.alt, 38 | }), 39 | ) 40 | 41 | return ( 42 | !open && setImage(null)}> 43 | 44 | 45 | 46 | {image?.alt || 'Preview image'} 47 | 48 | 49 | {image && ( 50 | {image.alt 55 | )} 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/layouts/course-sidebar-layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, Suspense } from 'react' 2 | 3 | import { CourseSidebar } from '@/components/course-sidebar' 4 | import CourseSidebarWithEnrollment from '@/components/course-sidebar/course-sidebar-with-enrollment' 5 | import { getCourseBySlugOrId, getCourseUnitsMap } from '@/server/db/courses/getters' 6 | import { CourseGenerating } from '@/components/courses/course-generating' 7 | 8 | export default async function CourseSidebarLayout({ 9 | children, 10 | courseSlug, 11 | }: { 12 | children: ReactNode 13 | courseSlug: string 14 | }) { 15 | const course = await getCourseBySlugOrId(courseSlug) 16 | 17 | if (!course) { 18 | console.warn(`Course with slug "${courseSlug}" not found`) 19 | return null 20 | } 21 | 22 | const courseUnits = await getCourseUnitsMap(course.id) 23 | 24 | if (!course.generatedAt || courseUnits.size === 0) { 25 | return 26 | } 27 | 28 | return ( 29 |
    30 | 37 | } 38 | > 39 | 44 | 45 | 46 |
    {children}
    47 |
    48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /components/layouts/header-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from '../header' 2 | 3 | interface Props { 4 | children: React.ReactNode 5 | courseId?: string 6 | } 7 | 8 | export function HeaderLayout({ children, courseId }: Props) { 9 | return ( 10 |
    11 |
    12 | 13 |
    {children}
    14 |
    15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/scrolling-textarea.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | import { Textarea, TextareaProps } from './ui/textarea' 4 | 5 | interface ScrollingTextareaProps extends TextareaProps { 6 | autoScroll?: boolean 7 | } 8 | 9 | export function ScrollingTextarea({ 10 | value, 11 | autoScroll = true, 12 | ...props 13 | }: ScrollingTextareaProps) { 14 | const ref = useRef(null) 15 | 16 | useEffect(() => { 17 | if (ref?.current && autoScroll) { 18 | ref.current.scrollTop = ref.current.scrollHeight 19 | } 20 | }, [autoScroll, value]) 21 | 22 | return