├── .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 |
33 |
34 | Terms of Service
35 | {' '}
36 | and{' '}
37 |
38 | Privacy Policy
39 |
40 |
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 |
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 | handleUnEnroll()}>
56 | Un-enroll
57 |
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 |
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 |
34 |
35 |
36 |
37 |
41 | Or generate your own course...
42 |
43 |
44 |
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 |
36 | {links.map((item) => (
37 |
48 | {item.title}
49 |
50 | ))}
51 |
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 |
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 |
58 |
59 | Completed
60 |
61 | ) : (
62 |
63 |
64 | Mark as complete
65 |
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 |
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 |
39 | ) : (
40 |
setError(true)}
46 | />
47 | )}
48 |
49 | {image.description && (
50 |
{description}
51 | )}
52 |
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 |
35 | Next up: {nextUnit.title}
36 |
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 |
55 | {enrolled ? (
56 |
57 | ) : (
58 |
59 | )}
60 | {enrolled ? 'Enrolled' : 'Enroll in course'}
61 |
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 | triggerEvent('command.dialog.open')}
14 | {...props}
15 | >
16 | Search
17 |
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 |
25 | {theme === 'dark' ? (
26 |
27 | ) : (
28 |
29 | )}
30 | Toggle theme
31 |
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 |
45 |
46 |
50 |
51 | )
52 | }
53 |
54 | function MoonIcon(props: SVGProps) {
55 | return (
56 |
57 |
58 |
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 |
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
23 | }
24 |
--------------------------------------------------------------------------------
/components/session-content/session-content.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface Props {
4 | content: string
5 | }
6 |
7 | const SessionContent: React.FC = ({ content }) => {
8 | return (
9 |
10 | {content.split('\n').map((para, i) => (
11 |
12 | {para}
13 |
14 | ))}
15 |
16 | )
17 | }
18 |
19 | export default SessionContent
20 |
--------------------------------------------------------------------------------
/components/sidebar-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 { cn } from '@/lib/utils'
8 |
9 | interface SidebarNavProps extends React.HTMLAttributes {
10 | items: {
11 | href: string
12 | title: string
13 | }[]
14 | }
15 |
16 | export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
17 | const pathname = usePathname()
18 |
19 | return (
20 |
24 | {items.map((item) => (
25 |
36 | {item.title}
37 |
38 | ))}
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { ThemeProvider as NextThemesProvider } from 'next-themes'
4 | import { ThemeProviderProps } from 'next-themes/dist/types'
5 | import * as React from 'react'
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children}
9 | }
10 |
--------------------------------------------------------------------------------
/components/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tooltip as TooltipBase,
3 | TooltipContent,
4 | TooltipProvider,
5 | TooltipTrigger,
6 | } from './ui/tooltip'
7 |
8 | interface TooltipProps {
9 | children: React.ReactNode
10 | title: string
11 | }
12 |
13 | export function Tooltip({ children, title }: TooltipProps) {
14 | return (
15 |
16 |
17 | {children}
18 | {title}
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as AvatarPrimitive from '@radix-ui/react-avatar'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority'
2 | import * as React from 'react'
3 |
4 | import { cn } from '@/lib/utils'
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | },
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return
32 | }
33 |
34 | export { Badge, badgeVariants }
35 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot'
2 | import { cva, type VariantProps } from 'class-variance-authority'
3 | import * as React from 'react'
4 |
5 | import { cn } from '@/lib/utils'
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90',
13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
14 | outline:
15 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
16 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
17 | ghost: 'hover:bg-accent hover:text-accent-foreground',
18 | link: 'text-primary underline-offset-4 hover:underline',
19 | },
20 | size: {
21 | default: 'h-10 px-4 py-2',
22 | sm: 'h-9 rounded-md px-3',
23 | lg: 'h-11 rounded-md px-8',
24 | icon: 'h-10 w-10',
25 | },
26 | },
27 | defaultVariants: {
28 | variant: 'default',
29 | size: 'default',
30 | },
31 | },
32 | )
33 |
34 | export interface ButtonProps
35 | extends React.ButtonHTMLAttributes,
36 | VariantProps {
37 | asChild?: boolean
38 | }
39 |
40 | const Button = React.forwardRef(
41 | ({ className, variant, size, asChild = false, ...props }, ref) => {
42 | const Comp = asChild ? Slot : 'button'
43 | return (
44 |
49 | )
50 | },
51 | )
52 | Button.displayName = 'Button'
53 |
54 | export { Button, buttonVariants }
55 |
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | const Card = React.forwardRef>(
6 | ({ className, ...props }, ref) => (
7 |
15 | ),
16 | )
17 | Card.displayName = 'Card'
18 |
19 | const CardHeader = React.forwardRef>(
20 | ({ className, ...props }, ref) => (
21 |
26 | ),
27 | )
28 | CardHeader.displayName = 'CardHeader'
29 |
30 | const CardTitle = React.forwardRef<
31 | HTMLParagraphElement,
32 | React.HTMLAttributes
33 | >(({ className, ...props }, ref) => (
34 |
39 | ))
40 | CardTitle.displayName = 'CardTitle'
41 |
42 | const CardDescription = React.forwardRef<
43 | HTMLParagraphElement,
44 | React.HTMLAttributes
45 | >(({ className, ...props }, ref) => (
46 |
47 | ))
48 | CardDescription.displayName = 'CardDescription'
49 |
50 | const CardContent = React.forwardRef<
51 | HTMLDivElement,
52 | React.HTMLAttributes
53 | >(({ className, ...props }, ref) => (
54 |
55 | ))
56 | CardContent.displayName = 'CardContent'
57 |
58 | const CardFooter = React.forwardRef>(
59 | ({ className, ...props }, ref) => (
60 |
61 | ),
62 | )
63 | CardFooter.displayName = 'CardFooter'
64 |
65 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
66 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
4 | import { Check } from 'lucide-react'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/components/ui/codeblock.tsx:
--------------------------------------------------------------------------------
1 | export function CodeBlock({ value }: { value: string; language: string }) {
2 | return (
3 |
4 | {value}
5 |
6 | )
7 | }
8 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | export interface InputProps extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | )
20 | },
21 | )
22 | Input.displayName = 'Input'
23 |
24 | export { Input }
25 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as LabelPrimitive from '@radix-ui/react-label'
4 | import { cva, type VariantProps } from 'class-variance-authority'
5 | import * as React from 'react'
6 |
7 | import { cn } from '@/lib/utils'
8 |
9 | const labelVariants = cva(
10 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
19 | ))
20 | Label.displayName = LabelPrimitive.Root.displayName
21 |
22 | export { Label }
23 |
--------------------------------------------------------------------------------
/components/ui/markdown.ts:
--------------------------------------------------------------------------------
1 | import { FC, memo } from 'react'
2 | import ReactMarkdown, { Options } from 'react-markdown'
3 |
4 | export const MemoizedReactMarkdown: FC = memo(
5 | ReactMarkdown,
6 | (prevProps, nextProps) =>
7 | prevProps.children === nextProps.children &&
8 | prevProps.className === nextProps.className,
9 | )
10 |
--------------------------------------------------------------------------------
/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as ProgressPrimitive from '@radix-ui/react-progress'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ))
26 | Progress.displayName = ProgressPrimitive.Root.displayName
27 |
28 | export { Progress }
29 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as SeparatorPrimitive from '@radix-ui/react-separator'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
12 |
23 | ))
24 | Separator.displayName = SeparatorPrimitive.Root.displayName
25 |
26 | export { Separator }
27 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '@/lib/utils'
2 |
3 | function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return
5 | }
6 |
7 | export { Skeleton }
8 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | import { cn } from '@/lib/utils'
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | },
21 | )
22 | Textarea.displayName = 'Textarea'
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@/components/ui/toast'
11 | import { useToast } from '@/components/ui/use-toast'
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && {description} }
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'
4 | import * as React from 'react'
5 |
6 | import { cn } from '@/lib/utils'
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/lib/assert.ts:
--------------------------------------------------------------------------------
1 | class AssertError extends Error {
2 | constructor(message?: string) {
3 | super(message)
4 | this.name = 'AssertError'
5 | }
6 | }
7 |
8 | export function assert(condition: any, message?: string): asserts condition {
9 | if (!condition) {
10 | throw new AssertError(message)
11 | }
12 | }
13 |
14 | export function assertString(value: any, message?: string): asserts value is string {
15 | if (typeof value !== 'string' || value.length === 0) {
16 | throw new AssertError(message)
17 | }
18 | }
19 |
20 | export function assertNumber(value: any, message?: string): asserts value is number {
21 | if (typeof value !== 'number' || isNaN(value)) {
22 | throw new AssertError(message)
23 | }
24 | }
25 |
26 | export function assertArray(value: any, message?: string): asserts value is T[] {
27 | if (!Array.isArray(value)) {
28 | throw new AssertError(message)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/lib/cip-category.ts:
--------------------------------------------------------------------------------
1 | import { slugify } from '@/lib/slugify'
2 |
3 | type CIPCategorySansSlug = {
4 | title: string
5 | codes: string[]
6 | }
7 |
8 | export type CIPCategory = CIPCategorySansSlug & {
9 | slug: string
10 | }
11 |
12 | const CIP_CATEGORIES_SANS_SLUG: CIPCategorySansSlug[] = [
13 | {
14 | title: 'Sciences and Mathematics',
15 | codes: ['01', '03', '26', '27', '40', '42'],
16 | },
17 | {
18 | title: 'Engineering and Technology',
19 | codes: ['11', '14', '15', '29', '41'],
20 | },
21 | {
22 | title: 'Arts, Literature, and Communication',
23 | codes: ['04', '05', '09', '10', '23', '50', '54', '55'],
24 | },
25 | {
26 | // Social Sciences, Legal, and Public Administration
27 | title: 'Social Sciences, Legal, and Admin',
28 | codes: ['22', '43', '44', '45'],
29 | },
30 | {
31 | title: 'Personal Services, Health, and Safety',
32 | codes: ['12', '13', '31', '32', '33', '34', '35', '36', '37', '39', '51'],
33 | },
34 | {
35 | title: 'Business and Management',
36 | codes: ['52'],
37 | },
38 | {
39 | title: 'Interdisciplinary and Miscellaneous',
40 | codes: ['16', '19', '24', '25', '30', '46', '47', '48', '49', '60'],
41 | },
42 | ]
43 |
44 | export const CIP_CATEGORIES: CIPCategory[] = CIP_CATEGORIES_SANS_SLUG.map((category) => ({
45 | ...category,
46 | slug: slugify(category.title),
47 | }))
48 |
--------------------------------------------------------------------------------
/lib/description.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Removes markdown specific characters
3 | *
4 | * @param description - The input string to be cleaned.
5 | * @returns The cleaned description string
6 | */
7 |
8 | export function cleanDescription(description: string): string {
9 | // - Strip `#` `*`, `_` and `[]()` from description
10 | // Strip html tags
11 |
12 | return description
13 | .replace(/[`#*_]/g, '')
14 | .replace(/\[(.*?)\]\(.*?\)/g, '$1')
15 | .replace(/<[^>]*>/g, '')
16 | }
17 |
--------------------------------------------------------------------------------
/lib/enum.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Converts string to enum. Returns undefined if enum doesn't contain the value.
3 | *
4 | * Taken from: https://stackoverflow.com/a/41548441
5 | */
6 | export function enumFromString(
7 | enm: { [s: string]: T },
8 | value: string | undefined | null,
9 | ): T | undefined {
10 | if (!value) return
11 |
12 | const validValues = Object.values(enm) as unknown as string[]
13 | const isValid = validValues.includes(value)
14 |
15 | if (isValid) {
16 | return value as unknown as T
17 | } else {
18 | return undefined
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/lib/generate-id.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid'
2 |
3 | export const generateId = () => v4()
4 |
5 | export const generateApiKey = () => `sk-${v4().replace(/-/g, '')}`
6 |
--------------------------------------------------------------------------------
/lib/json-fetch.ts:
--------------------------------------------------------------------------------
1 | interface FetchOptions {
2 | method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
3 | data?: any
4 | }
5 |
6 | interface FetchError {
7 | type: string
8 | message: string
9 | }
10 |
11 | export async function jsonFetch(
12 | url: string,
13 | { method, data }: FetchOptions = {},
14 | ): Promise<{
15 | error: FetchError | undefined
16 | response: R
17 | }> {
18 | const response = await fetch(url, {
19 | method,
20 | headers: {
21 | ...(data ? { 'Content-Type': 'application/json' } : {}),
22 | },
23 | body: data ? JSON.stringify(data) : undefined,
24 | })
25 |
26 | let error: FetchError | undefined
27 |
28 | let json: any | undefined
29 |
30 | try {
31 | json = await response.json()
32 | } catch (error) {
33 | console.error(error)
34 | }
35 |
36 | if (!response.ok || !json) {
37 | error = json?.error ?? {
38 | type: 'unknown',
39 | message: 'Something went wrong',
40 | }
41 | }
42 |
43 | return {
44 | error,
45 | response: json,
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/lib/lodash-fns.ts:
--------------------------------------------------------------------------------
1 | export function groupByUsingMap(items: T[], iteratee: (item: T) => Y): Map {
2 | return items.reduce((map, item) => {
3 | const key = iteratee(item)
4 | const list = map.get(key) || []
5 |
6 | list.push(item)
7 | map.set(key, list)
8 |
9 | return map
10 | }, new Map())
11 | }
12 |
13 | export function collectByUsingMap(items: T[], iteratee: (item: T) => Y): Map {
14 | return items.reduce((map, item) => {
15 | const key = iteratee(item)
16 | map.set(key, item)
17 | return map
18 | }, new Map())
19 | }
20 |
--------------------------------------------------------------------------------
/lib/lodash-memoize.ts:
--------------------------------------------------------------------------------
1 | import memoizeBase from 'lodash/memoize'
2 |
3 | /*
4 | * This is a decorator that memoizes the result of a method.
5 | * Usage: @memoize() method() { ... }
6 | */
7 | export function memoize() {
8 | return function (_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
9 | if (descriptor.get) {
10 | descriptor.get = memoizeBase(descriptor.get, function (this: T): T {
11 | return this
12 | })
13 | } else {
14 | descriptor.value = memoizeBase(descriptor.value)
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/lib/not-empty.ts:
--------------------------------------------------------------------------------
1 | export function notEmpty(value: TValue | null | undefined): value is TValue {
2 | return value !== null && value !== undefined
3 | }
4 |
--------------------------------------------------------------------------------
/lib/readline.ts:
--------------------------------------------------------------------------------
1 | import { stdin as input, stdout as output } from 'node:process'
2 | import * as readline from 'node:readline/promises'
3 |
4 | export async function prompt(question: string) {
5 | const rl = readline.createInterface({ input, output })
6 | const answer = await rl.question(question)
7 | rl.close()
8 |
9 | return answer
10 | }
11 |
--------------------------------------------------------------------------------
/lib/resend.ts:
--------------------------------------------------------------------------------
1 | type Tag = { name: string; value: string }
2 |
3 | interface Attachment {
4 | content?: string | Buffer
5 | filename?: string | false | undefined
6 | path?: string
7 | }
8 |
9 | interface CreateEmail {
10 | attachments?: Attachment[]
11 | bcc?: string | string[]
12 | cc?: string | string[]
13 | from: string
14 | headers?: Record
15 | html: string
16 | text?: string
17 | reply_to?: string | string[]
18 | subject: string
19 | tags?: Tag[]
20 | to: string | string[]
21 | }
22 |
23 | interface CreateEmailOptions {
24 | apiKey?: string
25 | }
26 |
27 | interface CreateEmailResponse {
28 | id: string
29 | }
30 |
31 | class ResendError extends Error {
32 | constructor(message: string) {
33 | super(message)
34 | this.name = 'ResendError'
35 | }
36 | }
37 |
38 | const defaultApiKey = process.env.RESEND_API_KEY
39 |
40 | export async function createEmail(
41 | params: CreateEmail,
42 | { apiKey }: CreateEmailOptions = {
43 | apiKey: defaultApiKey,
44 | },
45 | ): Promise {
46 | if (!apiKey) {
47 | throw new ResendError('createEmail failed: apiKey is required')
48 | }
49 |
50 | const response = await fetch('https://api.resend.com/emails', {
51 | method: 'POST',
52 | headers: {
53 | 'Content-Type': 'application/json',
54 | Authorization: `Bearer ${apiKey}`,
55 | },
56 | body: JSON.stringify(params),
57 | })
58 |
59 | if (!response.ok) {
60 | throw new ResendError(`createEmail failed: ${await response.text()}`)
61 | }
62 |
63 | return await response.json()
64 | }
65 |
--------------------------------------------------------------------------------
/lib/sleep.ts:
--------------------------------------------------------------------------------
1 | export function sleep(ms: number) {
2 | return new Promise((resolve) => setTimeout(resolve, ms))
3 | }
4 |
--------------------------------------------------------------------------------
/lib/slugify.ts:
--------------------------------------------------------------------------------
1 | import slugifyBase from '@sindresorhus/slugify'
2 |
3 | export function slugify(str: string) {
4 | return slugifyBase(str.toLowerCase())
5 | }
6 |
--------------------------------------------------------------------------------
/lib/titlize.tsx:
--------------------------------------------------------------------------------
1 | export function titlize(str: string) {
2 | // Make sure first letter is capitalized
3 | str = str.charAt(0).toUpperCase() + str.slice(1)
4 |
5 | // Add a period if it's missing
6 | if (!str.endsWith('.')) {
7 | str += '.'
8 | }
9 |
10 | return str
11 | }
12 |
--------------------------------------------------------------------------------
/lib/use-abort-controller.tsx:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react'
2 |
3 | export function useAbortController() {
4 | const abortRef = useRef(null)
5 |
6 | function createSignal() {
7 | abort()
8 | abortRef.current = new AbortController()
9 | return abortRef.current?.signal
10 | }
11 |
12 | function abort() {
13 | abortRef.current?.abort()
14 | }
15 |
16 | return { createSignal, abort }
17 | }
18 |
--------------------------------------------------------------------------------
/lib/use-debounced-state.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export function useDebouncedState(
4 | initialState: T,
5 | delay = 100,
6 | ): [T, (value: T) => void] {
7 | const [state, setState] = useState(initialState)
8 | const [debouncedState, setDebouncedState] = useState(initialState)
9 |
10 | useEffect(() => {
11 | const timeoutId = setTimeout(() => setDebouncedState(state), delay)
12 | return () => clearTimeout(timeoutId)
13 | }, [delay, state])
14 |
15 | return [debouncedState, setState]
16 | }
17 |
--------------------------------------------------------------------------------
/lib/use-event-listener.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | export function useEventListener(
4 | type: K,
5 | listener: (this: Window, ev: WindowEventMap[K]) => any,
6 | options?: boolean | AddEventListenerOptions,
7 | ) {
8 | useEffect(() => {
9 | window.addEventListener(type, listener, options)
10 |
11 | return () => {
12 | window.removeEventListener(type, listener, options)
13 | }
14 | }, [listener, options, type])
15 | }
16 |
17 | export function triggerEvent(
18 | type: K,
19 | options?: CustomEventInit,
20 | ) {
21 | window.dispatchEvent(new CustomEvent(type, options))
22 | }
23 |
--------------------------------------------------------------------------------
/lib/use-keyboard-shortcut.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | interface KeyboardShortcutOptions {
4 | metaKey?: boolean
5 | }
6 |
7 | export function useKeyboardShortcut(
8 | key: string,
9 | callback: () => void,
10 | { metaKey = false }: KeyboardShortcutOptions = {},
11 | ) {
12 | useEffect(() => {
13 | const handleKeyPress = (event: KeyboardEvent) => {
14 | if (event.key !== key) {
15 | return
16 | }
17 |
18 | if (metaKey && !event.metaKey) {
19 | return
20 | }
21 |
22 | event.preventDefault()
23 | callback()
24 | }
25 |
26 | document.addEventListener('keydown', handleKeyPress)
27 |
28 | return () => {
29 | document.removeEventListener('keydown', handleKeyPress)
30 | }
31 | }, [key, metaKey, callback])
32 | }
33 |
--------------------------------------------------------------------------------
/lib/use-loading.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 |
3 | export function useLoading() {
4 | const [loading, setLoading] = useState(false)
5 |
6 | async function withLoading(fn: () => Promise | void) {
7 | if (loading) {
8 | return
9 | }
10 |
11 | setLoading(true)
12 |
13 | try {
14 | await fn()
15 | } finally {
16 | setLoading(false)
17 | }
18 | }
19 |
20 | return {
21 | loading,
22 | withLoading,
23 | setLoading,
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/lib/use-message.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 |
3 | export function useMessageListener(callback: (event: MessageEvent) => void) {
4 | useEffect(() => {
5 | const listener = (event: MessageEvent) => {
6 | callback(event)
7 | }
8 | window.addEventListener('message', listener)
9 | return () => {
10 | window.removeEventListener('message', listener)
11 | }
12 | }, [callback])
13 | }
14 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx'
2 | import { twMerge } from 'tailwind-merge'
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/lib/uuid.ts:
--------------------------------------------------------------------------------
1 | import { v4 } from 'uuid'
2 |
3 | export const uuid = v4
4 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 |
5 | experimental: {
6 | serverComponentsExternalPackages: ['shiki', 'vscode-oniguruma', 'vscode-textmate'],
7 | },
8 |
9 | staticPageGenerationTimeout: 1000,
10 |
11 | redirects: async () => {
12 | return [
13 | {
14 | source: '/account',
15 | destination: '/account/courses',
16 | permanent: false,
17 | },
18 | {
19 | source: '/api/redirects/:path*',
20 | destination: '/api/redirect/:path*',
21 | permanent: false,
22 | },
23 | ]
24 | },
25 | }
26 |
27 | module.exports = nextConfig
28 |
--------------------------------------------------------------------------------
/pages/api/dev/emails/course-created.ts:
--------------------------------------------------------------------------------
1 | import { renderAsync } from '@react-email/render'
2 |
3 | import { CourseCreatedEmail } from '@/server/emails/course-created-email'
4 |
5 | export const config = {
6 | runtime: 'edge',
7 | }
8 |
9 | export default async function handle() {
10 | const element = CourseCreatedEmail({
11 | userName: 'John Doe',
12 | courseSlug: 'course-slug',
13 | courseTitle: 'Course Title',
14 | courseDescription: 'Course Description',
15 | })
16 |
17 | const html = await renderAsync(element)
18 |
19 | return new Response(html, {
20 | headers: {
21 | 'Content-Type': 'text/html',
22 | },
23 | })
24 | }
25 |
--------------------------------------------------------------------------------
/pages/api/dev/emails/course-unit.ts:
--------------------------------------------------------------------------------
1 | import { renderAsync } from '@react-email/render'
2 |
3 | import { assert } from '@/lib/assert'
4 | import { db } from '@/server/db/edge-db'
5 | import { CourseUnitEmail } from '@/server/emails/course-unit-email'
6 |
7 | export const config = {
8 | runtime: 'edge',
9 | }
10 |
11 | export default async function handle() {
12 | const course = await db.selectFrom('courses').selectAll().executeTakeFirst()
13 | assert(course, 'course not found')
14 |
15 | const courseModule = await db
16 | .selectFrom('course_modules')
17 | .selectAll()
18 | .where('courseId', '=', course.id)
19 | .executeTakeFirst()
20 | assert(courseModule, 'module not found')
21 |
22 | const courseUnit = await db
23 | .selectFrom('course_module_units')
24 | .selectAll()
25 | .where('moduleId', '=', courseModule.id)
26 | .executeTakeFirst()
27 | assert(courseUnit, 'unit not found')
28 |
29 | const email = 'john@example.com'
30 |
31 | const element = CourseUnitEmail({
32 | course,
33 | courseModule,
34 | courseUnit,
35 | email,
36 | })
37 |
38 | const html = await renderAsync(element)
39 |
40 | return new Response(html, {
41 | headers: {
42 | 'Content-Type': 'text/html',
43 | },
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/pages/api/inngest.ts:
--------------------------------------------------------------------------------
1 | import { serve } from 'inngest/next'
2 |
3 | import { inngest } from '@/server/jobs/client'
4 | import { courseGenerate } from '@/server/jobs/functions/course-generate'
5 | import { courseSubscribe } from '@/server/jobs/functions/course-subscribe'
6 |
7 | export const runtime = 'edge'
8 |
9 | export default serve({
10 | client: inngest,
11 | functions: [courseGenerate, courseSubscribe],
12 | streaming: 'allow',
13 | })
14 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 90,
3 | semi: false,
4 | trailingComma: 'all',
5 | singleQuote: true,
6 | jsxBracketSameLine: false,
7 | arrowParens: 'always',
8 | plugins: [],
9 | }
10 |
--------------------------------------------------------------------------------
/public/static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maccman/101-school/c02d8b1027c9146b1f349ac6e19071ef83b8f16a/public/static/logo.png
--------------------------------------------------------------------------------
/public/static/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maccman/101-school/c02d8b1027c9146b1f349ac6e19071ef83b8f16a/public/static/twitter.png
--------------------------------------------------------------------------------
/scripts/backfill-course-cip.ts:
--------------------------------------------------------------------------------
1 | import { Updateable } from 'kysely'
2 |
3 | import { db } from '@/server/db/db'
4 | import { Course } from '@/server/db/schema'
5 | import { parseCourseCip } from '@/server/helpers/ai/prompts/parse-course-cip'
6 |
7 | async function main() {
8 | const courses = await getCourses()
9 |
10 | for (const course of courses) {
11 | if (course.cipCode) {
12 | continue
13 | }
14 |
15 | const headline =
16 | course.parsedContent?.headline ||
17 | course.parsedContent?.outline ||
18 | course.description
19 |
20 | if (!headline) {
21 | console.warn(`No headline for course ${course.id}`)
22 | continue
23 | }
24 |
25 | const result = await parseCourseCip(headline)
26 |
27 | if (!result.cipCode) {
28 | console.warn(`No CIP code for course ${course.id}`)
29 | continue
30 | }
31 |
32 | console.log(
33 | `Updating course ${course.id} with CIP code ${result.cipCode} / ${result.cipTitle}`,
34 | )
35 |
36 | await updateCourse(course.id, {
37 | cipCode: result.cipCode || null,
38 | cipTitle: result.cipTitle || null,
39 | })
40 | }
41 | }
42 |
43 | async function getCourses() {
44 | const records = await db
45 | .selectFrom('courses')
46 | .selectAll()
47 | .orderBy('title', 'asc')
48 | .execute()
49 |
50 | return records
51 | }
52 |
53 | async function updateCourse(id: string, values: Updateable) {
54 | await db
55 | .updateTable('courses')
56 | .set(values)
57 | .where('id', '=', id)
58 | .returning('id')
59 | .executeTakeFirstOrThrow()
60 | }
61 |
62 | main()
63 |
--------------------------------------------------------------------------------
/scripts/backfill-parse-ddc.ts:
--------------------------------------------------------------------------------
1 | import { Updateable } from 'kysely'
2 | import { retry } from 'ts-retry'
3 |
4 | import { db } from '@/server/db/db'
5 | import { Course } from '@/server/db/schema'
6 | import { parseCourseDeweyDecimalClass } from '@/server/helpers/ai/prompts/parse-course-ddc'
7 |
8 | async function main() {
9 | const courses = await getCourses()
10 |
11 | for (const course of courses) {
12 | if (course.ddcCode) {
13 | console.log(`Course ${course.id} already has a DDC code`)
14 | continue
15 | }
16 |
17 | const headline =
18 | course.parsedContent?.headline ||
19 | course.parsedContent?.outline ||
20 | course.description
21 |
22 | if (!headline) {
23 | console.warn(`No headline for course ${course.id}`)
24 | continue
25 | }
26 |
27 | try {
28 | await retry(
29 | async () => {
30 | const result = await parseCourseDeweyDecimalClass(headline)
31 |
32 | if (!result.ddcCode) {
33 | console.warn(`No DDC code for course ${course.id}`)
34 | return
35 | }
36 |
37 | console.log(
38 | `Updating course ${course.id}/${course.title} with ddc ${result.ddcCode} / ${result.ddcTitle}`,
39 | )
40 |
41 | await updateCourse(course.id, {
42 | ddcCode: result.ddcCode || null,
43 | ddcTitle: result.ddcTitle || null,
44 | })
45 | },
46 | { maxTry: 5, delay: 5000 },
47 | )
48 | } catch (error) {
49 | console.error(error)
50 | }
51 | }
52 | }
53 |
54 | async function getCourses() {
55 | const records = await db
56 | .selectFrom('courses')
57 | .selectAll()
58 | .orderBy('title', 'asc')
59 | .execute()
60 |
61 | return records
62 | }
63 |
64 | async function updateCourse(id: string, values: Updateable) {
65 | await db
66 | .updateTable('courses')
67 | .set(values)
68 | .where('id', '=', id)
69 | .returning('id')
70 | .executeTakeFirstOrThrow()
71 | }
72 |
73 | main()
74 |
--------------------------------------------------------------------------------
/scripts/generate-sample-course.ts:
--------------------------------------------------------------------------------
1 | import { prompt } from '@/lib/readline'
2 | import { slugify } from '@/lib/slugify'
3 | import { createCourse, updateCourse } from '@/server/db/courses/setters'
4 | import { getUsers } from '@/server/db/users/getters'
5 | import { generateCourse } from '@/server/helpers/ai/prompts/generate-course'
6 | import { parseCourse } from '@/server/helpers/ai/prompts/parse-course'
7 | import { parseCourseCip } from '@/server/helpers/ai/prompts/parse-course-cip'
8 |
9 | async function main() {
10 | console.log('Generating course...')
11 | const title = await prompt('Course title: ')
12 |
13 | const content = await generateCourse(title)
14 |
15 | const ownerId = (await getUsers())[0].id
16 |
17 | const courseId = await createCourse({
18 | title,
19 | ownerId,
20 | slug: slugify(title),
21 | description: `A course about ${title}`,
22 | content,
23 | })
24 |
25 | console.log('Parsing course...')
26 | const parsedContent = await parseCourse(content)
27 |
28 | const parsedCip = await safeParseCouseCip(
29 | parsedContent.headline || parsedContent.outline || title,
30 | )
31 |
32 | await updateCourse(courseId, {
33 | parsedContent,
34 | cipCode: parsedCip.cipCode || null,
35 | cipTitle: parsedCip.cipTitle || null,
36 | })
37 |
38 | console.log('Done!')
39 | }
40 |
41 | function safeParseCouseCip(headline: string) {
42 | try {
43 | return parseCourseCip(headline)
44 | } catch (error) {
45 | console.error(error)
46 | return {
47 | cipCode: null,
48 | cipTitle: null,
49 | }
50 | }
51 | }
52 |
53 | main()
54 |
--------------------------------------------------------------------------------
/scripts/generate-sample-module.ts:
--------------------------------------------------------------------------------
1 | import { Selectable } from 'kysely'
2 |
3 | import { assert } from '@/lib/assert'
4 | import { prompt } from '@/lib/readline'
5 | import { slugify } from '@/lib/slugify'
6 | import { getCourseBySlug } from '@/server/db/courses/getters'
7 | import { setModule } from '@/server/db/modules/setters'
8 | import { Course, CourseParsedModule } from '@/server/db/schema'
9 | import { generateModule } from '@/server/helpers/ai/prompts/generate-module'
10 |
11 | async function main() {
12 | const title = await prompt('Course title: ')
13 |
14 | const course = await getCourseBySlug(slugify(title))
15 |
16 | assert(course)
17 |
18 | await Promise.all(
19 | course.parsedContent.modules.map((mod) => generateAndSaveModule(mod, course)),
20 | )
21 | }
22 |
23 | main()
24 |
25 | async function generateAndSaveModule(
26 | parsedModule: CourseParsedModule,
27 | course: Selectable,
28 | ) {
29 | console.log(`Generating module ${parsedModule.week}...`)
30 |
31 | const moduleBody = await generateModule({
32 | courseDescription: course.description,
33 | courseBody: course.content,
34 | moduleNumber: parsedModule.week,
35 | })
36 |
37 | await setModule({
38 | courseId: course.id,
39 | title: parsedModule.title,
40 | content: moduleBody,
41 | number: parsedModule.week,
42 | })
43 | }
44 |
--------------------------------------------------------------------------------
/scripts/parse-sample-course-cip.ts:
--------------------------------------------------------------------------------
1 | import { prompt } from '@/lib/readline'
2 | import { parseCourseCip } from '@/server/helpers/ai/prompts/parse-course-cip'
3 |
4 | async function main() {
5 | console.log('Generating course CIP...')
6 | const title = await prompt('Course description: ')
7 |
8 | const result = await parseCourseCip(title)
9 |
10 | console.log(result)
11 | }
12 |
13 | main()
14 |
--------------------------------------------------------------------------------
/scripts/test-course-units-map.ts:
--------------------------------------------------------------------------------
1 | import { prompt } from '@/lib/readline'
2 | import { getCourseUnitsMap } from '@/server/db/courses/getters'
3 |
4 | async function main() {
5 | console.log('Getting course units map...')
6 |
7 | const courseId = await prompt('Course ID: ')
8 |
9 | const result = await getCourseUnitsMap(courseId)
10 |
11 | console.log(result)
12 | }
13 |
14 | main()
15 |
--------------------------------------------------------------------------------
/server/db/course_subscriptions/getters.ts:
--------------------------------------------------------------------------------
1 | import { db } from '../edge-db'
2 |
3 | export async function getCourseSubscription(courseId: string) {
4 | const record = await db
5 | .selectFrom('course_subscriptions')
6 | .where('id', '=', courseId)
7 | .selectAll()
8 | .executeTakeFirst()
9 |
10 | return record ?? null
11 | }
12 |
13 | export async function getCourseSubscriptionByCourseEmail({
14 | courseId,
15 | email,
16 | }: {
17 | courseId: string
18 | email: string
19 | }) {
20 | const record = await db
21 | .selectFrom('course_subscriptions')
22 | .where('courseId', '=', courseId)
23 | .where('email', '=', email)
24 | .selectAll()
25 | .executeTakeFirst()
26 |
27 | return record ?? null
28 | }
29 |
--------------------------------------------------------------------------------
/server/db/course_subscriptions/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { CourseSubscription } from '../schema'
5 |
6 | export async function createCourseSubscription(values: Insertable) {
7 | const { id } = await db
8 | .insertInto('course_subscriptions')
9 | .values(values)
10 | .returning('id')
11 | .executeTakeFirstOrThrow()
12 |
13 | return id
14 | }
15 |
16 | export async function deleteCourseSubscription(courseSubscriptionId: string) {
17 | await db
18 | .deleteFrom('course_subscriptions')
19 | .where('id', '=', courseSubscriptionId)
20 | .execute()
21 | }
22 |
--------------------------------------------------------------------------------
/server/db/courses/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable, Updateable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { Course } from '../schema'
5 |
6 | export async function createCourse(values: Insertable) {
7 | const { id } = await db
8 | .insertInto('courses')
9 | .values(values)
10 | .returning('id')
11 | .executeTakeFirstOrThrow()
12 |
13 | return id
14 | }
15 |
16 | export async function updateCourse(id: string, values: Updateable) {
17 | await db
18 | .updateTable('courses')
19 | .set(values)
20 | .where('id', '=', id)
21 | .returning('id')
22 | .executeTakeFirstOrThrow()
23 | }
24 |
--------------------------------------------------------------------------------
/server/db/courses/types.ts:
--------------------------------------------------------------------------------
1 | import { Selectable } from 'kysely'
2 |
3 | import type { getCourseUnitsMap } from './getters'
4 | import type { Course as DbCourse, UnitImage } from '../schema'
5 |
6 | type PromiseType = T extends Promise ? U : never
7 |
8 | export interface CourseModule {
9 | id: string
10 | number: number
11 | title: string
12 | units: {
13 | id: string
14 | number: number
15 | title: string
16 | }[]
17 | }
18 |
19 | export type Course = Selectable
20 | export type CourseUnits = PromiseType>
21 | export type CourseWithImage = Course & { image: UnitImage | null }
22 |
23 | export type CourseSansContent = Omit
24 |
25 | // Courses with their contents are quite large, so we have a separate type for
26 | // stripped down courses that don't include the content.
27 | export const COURSE_SANS_CONTENT_KEYS = [
28 | 'courses.id',
29 | 'courses.slug',
30 | 'courses.title',
31 | 'courses.description',
32 | 'courses.cipCode',
33 | 'courses.cipTitle',
34 | 'courses.ddcCode',
35 | 'courses.ddcTitle',
36 | 'courses.language',
37 | 'courses.targeting',
38 | 'courses.weekCount',
39 | 'courses.generatedAt',
40 | 'courses.featuredAt',
41 | 'courses.ownerId',
42 | 'courses.createdAt',
43 | 'courses.updatedAt',
44 | 'courses.deletedAt',
45 | 'courses.stripePaymentIntentId',
46 | ] as const
47 |
--------------------------------------------------------------------------------
/server/db/db.ts:
--------------------------------------------------------------------------------
1 | import { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely'
2 | import { Pool } from 'pg'
3 |
4 | import { assertString } from '@/lib/assert'
5 |
6 | import { DB } from './schema'
7 |
8 | assertString(process.env.DATABASE_URL, 'DATABASE_URL is not set')
9 |
10 | const dialect = new PostgresDialect({
11 | pool: new Pool({
12 | connectionString: process.env.DATABASE_URL,
13 | ssl: true,
14 | }),
15 | })
16 |
17 | export const db = new Kysely({
18 | dialect,
19 | plugins: [new CamelCasePlugin()],
20 | })
21 |
--------------------------------------------------------------------------------
/server/db/edge-db.ts:
--------------------------------------------------------------------------------
1 | import { neonConfig } from '@neondatabase/serverless'
2 | import { CamelCasePlugin, Kysely } from 'kysely'
3 | import { NeonHTTPDialect } from 'kysely-neon'
4 |
5 | import { assertString } from '@/lib/assert'
6 |
7 | import { DB } from './schema'
8 |
9 | // Are we in a Node.js environment?
10 | if (typeof WebSocket === 'undefined') {
11 | neonConfig.webSocketConstructor = require('ws')
12 | }
13 |
14 | assertString(process.env.DATABASE_URL, 'DATABASE_URL is not set')
15 |
16 | export const db = new Kysely({
17 | dialect: new NeonHTTPDialect({
18 | connectionString: process.env.DATABASE_URL,
19 | }),
20 | plugins: [new CamelCasePlugin()],
21 | })
22 |
--------------------------------------------------------------------------------
/server/db/enrollment/getters.ts:
--------------------------------------------------------------------------------
1 | import { db } from '../edge-db'
2 |
3 | export async function getCourseEnrollment({
4 | userId,
5 | courseId,
6 | }: {
7 | userId: string
8 | courseId: string
9 | }) {
10 | const record = await db
11 | .selectFrom('course_enrollments')
12 | .selectAll()
13 | .where('userId', '=', userId)
14 | .where('courseId', '=', courseId)
15 | .executeTakeFirst()
16 |
17 | return record ?? null
18 | }
19 |
--------------------------------------------------------------------------------
/server/db/enrollment/setters.ts:
--------------------------------------------------------------------------------
1 | import { sql } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { getUnitCountForCourse } from '../units/getters'
5 |
6 | export async function markUnitAsComplete({
7 | userId,
8 | courseId,
9 | unitId,
10 | }: {
11 | userId: string
12 | courseId: string
13 | unitId: string
14 | }) {
15 | await db
16 | .updateTable('course_enrollments')
17 | .set({ completedUnitIds: sql`array_append(completed_unit_ids, ${unitId})` })
18 | .where('userId', '=', userId)
19 | .where('courseId', '=', courseId)
20 | .executeTakeFirstOrThrow()
21 | }
22 |
23 | export async function markUnitAsIncomplete({
24 | userId,
25 | courseId,
26 | unitId,
27 | }: {
28 | userId: string
29 | courseId: string
30 | unitId: string
31 | }) {
32 | await db
33 | .updateTable('course_enrollments')
34 | .set({
35 | completedUnitIds: sql`array_remove(completed_unit_ids, ${unitId})`,
36 | })
37 | .where('userId', '=', userId)
38 | .where('courseId', '=', courseId)
39 | .executeTakeFirstOrThrow()
40 | }
41 |
42 | export async function enrollInCourse({
43 | userId,
44 | courseId,
45 | }: {
46 | userId: string
47 | courseId: string
48 | }) {
49 | const unitCount = await getUnitCountForCourse(courseId)
50 |
51 | await db
52 | .insertInto('course_enrollments')
53 | .values({ userId, courseId, unitCount })
54 | .onConflict((oc) => oc.doNothing())
55 | .execute()
56 | }
57 |
58 | export async function unenrollFromCourse({
59 | userId,
60 | courseId,
61 | }: {
62 | userId: string
63 | courseId: string
64 | }) {
65 | await db
66 | .deleteFrom('course_enrollments')
67 | .where('userId', '=', userId)
68 | .where('courseId', '=', courseId)
69 | .execute()
70 | }
71 |
--------------------------------------------------------------------------------
/server/db/enrollment/types.ts:
--------------------------------------------------------------------------------
1 | import type { CourseEnrollment as DbUserCourse } from '../schema'
2 | import type { Selectable } from 'kysely'
3 |
4 | export type CourseEnrollment = Selectable
5 |
--------------------------------------------------------------------------------
/server/db/modules/getters.ts:
--------------------------------------------------------------------------------
1 | import { db } from '../edge-db'
2 |
3 | export async function getModulesByCourse(courseId: string) {
4 | const records = await db
5 | .selectFrom('course_modules')
6 | .where('courseId', '=', courseId)
7 | .selectAll()
8 | .execute()
9 |
10 | return records
11 | }
12 |
13 | export async function getModuleByNumber(courseId: string, number: number) {
14 | const record = await db
15 | .selectFrom('course_modules')
16 | .where('courseId', '=', courseId)
17 | .where('number', '=', number)
18 | .selectAll()
19 | .executeTakeFirst()
20 |
21 | return record ?? null
22 | }
23 |
24 | export async function getModule(moduleId: string) {
25 | const record = await db
26 | .selectFrom('course_modules')
27 | .where('id', '=', moduleId)
28 | .selectAll()
29 | .executeTakeFirst()
30 |
31 | return record ?? null
32 | }
33 |
--------------------------------------------------------------------------------
/server/db/modules/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { CourseModule } from '../schema'
5 |
6 | export async function createModule(values: Insertable) {
7 | const { id } = await db
8 | .insertInto('course_modules')
9 | .values(values)
10 | .returning('id')
11 | .executeTakeFirstOrThrow()
12 |
13 | return id
14 | }
15 |
16 | export async function setModule(values: Insertable) {
17 | const { id } = await db
18 | .insertInto('course_modules')
19 | .values(values)
20 | .onConflict((oc) => oc.columns(['courseId', 'number']).doUpdateSet(values))
21 | .returning('id')
22 | .executeTakeFirstOrThrow()
23 |
24 | return id
25 | }
26 |
--------------------------------------------------------------------------------
/server/db/modules/types.ts:
--------------------------------------------------------------------------------
1 | import { Selectable } from 'kysely'
2 |
3 | import type { CourseModule as DbCourseModule } from '../schema'
4 |
5 | export type CourseModule = Selectable
6 |
--------------------------------------------------------------------------------
/server/db/unit_chats/getters.ts:
--------------------------------------------------------------------------------
1 | import { db } from '../edge-db'
2 |
3 | export function getUnitChat({ unitId, userId }: { unitId: string; userId: string }) {
4 | return db
5 | .selectFrom('unit_chats')
6 | .selectAll()
7 | .where('unitId', '=', unitId)
8 | .where('userId', '=', userId)
9 | .orderBy('createdAt', 'asc')
10 | .executeTakeFirst()
11 | }
12 |
--------------------------------------------------------------------------------
/server/db/unit_chats/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { UnitChat } from '../schema'
5 |
6 | export function upsertUnitChat(values: Insertable) {
7 | return db
8 | .insertInto('unit_chats')
9 | .values(values)
10 | .onConflict((oc) => oc.columns(['unitId', 'userId']).doUpdateSet(values))
11 | .execute()
12 | }
13 |
--------------------------------------------------------------------------------
/server/db/unit_chats/types.ts:
--------------------------------------------------------------------------------
1 | import type { UnitChatMessage as DbUnitChatMessage } from '../schema'
2 |
3 | export type UnitChatMessage = DbUnitChatMessage
4 |
--------------------------------------------------------------------------------
/server/db/units/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable, Updateable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { CourseModuleUnit, UnitImage } from '../schema'
5 |
6 | export async function createUnit(values: Insertable) {
7 | const { id } = await db
8 | .insertInto('course_module_units')
9 | .values(values)
10 | .returning('id')
11 | .executeTakeFirstOrThrow()
12 |
13 | return id
14 | }
15 |
16 | export async function setUnit(values: Insertable) {
17 | const { id } = await db
18 | .insertInto('course_module_units')
19 | .values(values)
20 | .onConflict((oc) => oc.columns(['moduleId', 'number']).doUpdateSet(values))
21 | .returning('id')
22 | .executeTakeFirstOrThrow()
23 |
24 | return id
25 | }
26 |
27 | export async function updateUnit(unitId: string, values: Updateable) {
28 | const { id } = await db
29 | .updateTable('course_module_units')
30 | .set(values)
31 | .where('id', '=', unitId)
32 | .returning('id')
33 | .executeTakeFirstOrThrow()
34 |
35 | return id
36 | }
37 |
38 | export async function setUnitImages(
39 | unitId: string,
40 | values: { images: UnitImage[]; wikipediaUrls: string[] },
41 | ) {
42 | await db
43 | .updateTable('course_module_units')
44 | .set(values)
45 | .where('id', '=', unitId)
46 | .execute()
47 | }
48 |
--------------------------------------------------------------------------------
/server/db/units/types.ts:
--------------------------------------------------------------------------------
1 | import { Selectable } from 'kysely'
2 |
3 | import type { CourseModuleUnit as DbCourseModuleUnit } from '../schema'
4 |
5 | export type CourseModuleUnit = Selectable
6 |
--------------------------------------------------------------------------------
/server/db/users/getters.ts:
--------------------------------------------------------------------------------
1 | import { db } from '../edge-db'
2 |
3 | export async function getUser(userId: string) {
4 | const record = await db
5 | .selectFrom('users')
6 | .selectAll()
7 | .where('id', '=', userId)
8 | .executeTakeFirst()
9 |
10 | return record ?? null
11 | }
12 |
13 | export async function getUsers() {
14 | const records = await db
15 | .selectFrom('users')
16 | .selectAll()
17 | .orderBy('createdAt', 'asc')
18 | .execute()
19 |
20 | return records
21 | }
22 |
--------------------------------------------------------------------------------
/server/db/users/setters.ts:
--------------------------------------------------------------------------------
1 | import { Insertable } from 'kysely'
2 |
3 | import { db } from '../edge-db'
4 | import { User } from '../schema'
5 |
6 | export async function setUserAuth({
7 | id,
8 | emails,
9 | signInDate,
10 | }: {
11 | id: string
12 | emails: string[]
13 | signInDate: Date
14 | }) {
15 | // Insert or update the user's emails and last sign in time
16 | await db
17 | .insertInto('users')
18 | .values({
19 | id: id,
20 | emails: emails,
21 | lastSignInAt: signInDate,
22 | })
23 | .onConflict((oc) =>
24 | oc.column('id').doUpdateSet({
25 | emails: emails,
26 | lastSignInAt: signInDate,
27 | }),
28 | )
29 | .execute()
30 | }
31 |
32 | export async function setUser(userId: string, values: Insertable) {
33 | await db
34 | .updateTable('users')
35 | .set(values)
36 | .where('id', '=', userId)
37 | .executeTakeFirstOrThrow()
38 | }
39 |
--------------------------------------------------------------------------------
/server/emails/components/container.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@react-email/link'
2 | import * as React from 'react'
3 |
4 | import { styles } from '../styles'
5 |
6 | type ContainerElement = React.ElementRef<'table'>
7 | type RootProps = React.ComponentPropsWithoutRef<'table'>
8 |
9 | export interface ContainerProps extends RootProps {
10 | unsubscribeLink?: string
11 | }
12 |
13 | export const Container = React.forwardRef>(
14 | ({ children, unsubscribeLink, ...props }, forwardedRef) => {
15 | return (
16 | <>
17 |
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 |
35 | {unsubscribeLink && (
36 |
37 |
38 |
39 |
40 |
41 | Unsubscribe
42 |
43 |
44 |
45 |
46 |
47 | )}
48 | >
49 | )
50 | },
51 | )
52 |
53 | Container.displayName = 'Container'
54 |
--------------------------------------------------------------------------------
/server/emails/styles.ts:
--------------------------------------------------------------------------------
1 | export const styles: Record = {
2 | body: {
3 | fontFamily:
4 | '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
5 | fontSize: '16px',
6 | lineHeight: '1.5',
7 | marginTop: 'auto',
8 | marginBottom: 'auto',
9 | },
10 |
11 | logo: {
12 | display: 'block',
13 | marginLeft: 'auto',
14 | marginRight: 'auto',
15 | marginTop: 0,
16 | marginBottom: 0,
17 | },
18 |
19 | container: {
20 | borderRadius: '18px',
21 | border: '1px solid #e2e8f0',
22 | backgroundColor: '#fff',
23 | boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
24 | marginTop: '40px',
25 | marginBottom: '40px',
26 | marginLeft: 'auto',
27 | marginRight: 'auto',
28 | padding: '20px 40px',
29 | maxWidth: '900px',
30 | },
31 |
32 | heading: {
33 | fontSize: '24px',
34 | fontWeight: '600',
35 | lineHeight: '1.25',
36 | marginTop: '0',
37 | marginBottom: '1em',
38 | textAlign: 'center',
39 | },
40 |
41 | markdownHeading: {
42 | fontSize: '20px',
43 | fontWeight: '600',
44 | lineHeight: '1.25',
45 | marginTop: '1em',
46 | marginBottom: '0.5em',
47 | padding: 0,
48 | },
49 |
50 | text: {
51 | fontSize: '16px',
52 | lineHeight: '1.5',
53 | marginTop: '0',
54 | marginBottom: '16px',
55 | },
56 |
57 | link: {
58 | color: '#3182ce',
59 | textDecoration: 'underline',
60 | },
61 |
62 | section: {
63 | padding: '10px 0 32px 0',
64 | },
65 |
66 | unsubscribeLink: {
67 | color: '#3182ce',
68 | fontSize: '16px',
69 | lineHeight: '1.5',
70 | textAlign: 'center',
71 | },
72 | }
73 |
--------------------------------------------------------------------------------
/server/emails/utils.ts:
--------------------------------------------------------------------------------
1 | // Return a formatted first name, with a capital letter
2 | export function formatName(name: string) {
3 | const [givenName] = name.split(' ')
4 |
5 | return givenName.charAt(0).toUpperCase() + givenName.slice(1)
6 | }
7 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/generate-course.ts:
--------------------------------------------------------------------------------
1 | import { getPrediction } from '@/server/lib/anthropic/completion'
2 | import { ChatMessage } from '@/server/lib/anthropic/types'
3 |
4 | interface Options {
5 | weekCount?: number
6 | targeting?: string
7 | language?: string
8 | }
9 |
10 | export async function generateCourse(
11 | description: string,
12 | options: Options = {},
13 | ): Promise {
14 | return getPrediction({
15 | messages: generateCoursePrompt(description, options),
16 | temperature: 0.1,
17 | })
18 | }
19 |
20 | export function generateCoursePrompt(
21 | description: string,
22 | {
23 | weekCount = 13,
24 | targeting = 'adults late in their career',
25 | language = 'English',
26 | }: Options = {},
27 | ): ChatMessage[] {
28 | return [
29 | {
30 | role: 'user',
31 | content: `
32 | You are a university course generator. You are given a description of a course and you have to generate a course outline. Format output as Markdown.
33 |
34 | Design a university course outline:
35 |
36 | - Subject matter: ${description}
37 | - Target audience: ${targeting}
38 | - Number of weeks: ${weekCount}
39 | - Outline and course language: ${language}
40 |
41 | Each week will cover one module.
42 | Each module will be split into three to four units.
43 | The course is entirely virtual and online.
44 | There will be no exams so do not include those in the outline.
45 |
46 | Put a recommended reading list at the end of the outline.
47 | `.trim(),
48 | },
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/generate-module.ts:
--------------------------------------------------------------------------------
1 | import { getPrediction } from '@/server/lib/anthropic/completion'
2 | import { ChatMessage } from '@/server/lib/anthropic/types'
3 |
4 | import { generateCoursePrompt } from './generate-course'
5 |
6 | interface Params {
7 | courseDescription: string
8 | courseBody: string
9 | moduleNumber: number
10 | }
11 |
12 | interface Options {
13 | weekCount?: number
14 | targeting?: string
15 | language?: string
16 | }
17 |
18 | export function generateModule(params: Params, options: Options = {}) {
19 | return getPrediction({
20 | messages: generateModulePrompt(params, options),
21 | })
22 | }
23 |
24 | export function generateModulePrompt(
25 | { courseDescription, courseBody, moduleNumber }: Params,
26 | options: Options = {},
27 | ): ChatMessage[] {
28 | return [
29 | ...generateCoursePrompt(courseDescription, options),
30 | {
31 | role: 'assistant',
32 | content: courseBody,
33 | },
34 | {
35 | role: 'user',
36 | content: `Now expand on module ${moduleNumber} of the course. Write a clear and comprehensive overview list of the information covered in the module.`,
37 | },
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/generate-unit.ts:
--------------------------------------------------------------------------------
1 | import { getPrediction } from '@/server/lib/anthropic/completion'
2 | import { ChatMessage } from '@/server/lib/anthropic/types'
3 |
4 | import { generateModulePrompt } from './generate-module'
5 |
6 | interface Params {
7 | courseDescription: string
8 | courseBody: string
9 | moduleBody: string
10 | moduleNumber: number
11 | unitNumber: number
12 | }
13 |
14 | interface Options {
15 | weekCount?: number
16 | targeting?: string
17 | language?: string
18 | }
19 |
20 | export function generateUnit(params: Params, options: Options = {}) {
21 | return getPrediction({
22 | messages: generatePrompt(params, options),
23 | })
24 | }
25 |
26 | function generatePrompt(
27 | { courseDescription, courseBody, moduleNumber, moduleBody, unitNumber }: Params,
28 | options: Options = {},
29 | ): ChatMessage[] {
30 | return [
31 | ...generateModulePrompt({ courseDescription, courseBody, moduleNumber }, options),
32 | {
33 | role: 'assistant',
34 | content: moduleBody,
35 | },
36 | {
37 | role: 'user',
38 | content: `Now generate module ${moduleNumber} unit ${unitNumber} of the course. Write a clear and comprehensive article covering all the information in the unit. Omit the module and unit numbers from the title.`,
39 | },
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/generate-wikipedia-links.test.ts:
--------------------------------------------------------------------------------
1 | import { generateWikipediaUrls } from './generate-wikipedia-links'
2 |
3 | describe('generateWikipediaUrls', () => {
4 | it(
5 | 'returns relevant Wikipedia URLs for a given text body',
6 | async () => {
7 | const body = 'The Eiffel Tower is a famous landmark in Paris, France.'
8 | const urls = await generateWikipediaUrls(body)
9 |
10 | expect(urls).toContain('https://en.wikipedia.org/wiki/Eiffel_Tower')
11 | expect(urls).toContain('https://en.wikipedia.org/wiki/Paris')
12 | expect(urls.length).toBeLessThanOrEqual(5)
13 | },
14 | { timeout: 20000 },
15 | )
16 | })
17 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/generate-wikipedia-links.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { getPredictionForToolResult } from '@/server/lib/anthropic/functions'
4 | import { ChatMessage } from '@/server/lib/anthropic/types'
5 |
6 | const schema = z.object({
7 | wikipediaLinks: z.array(
8 | z.object({
9 | url: z.string().url().describe('The link URL'),
10 | }),
11 | ),
12 | })
13 |
14 | export async function generateWikipediaUrls(body: string): Promise {
15 | const result = await getPredictionForToolResult({
16 | messages: generatePrompt(body),
17 | schema,
18 | })
19 |
20 | return result.wikipediaLinks.map((link) => link.url)
21 | }
22 |
23 | function generatePrompt(body: string): ChatMessage[] {
24 | return [
25 | {
26 | role: 'user',
27 | content:
28 | `You are an advanced and accurate search bot that can find relevant Wikipedia links for any body of text',
29 |
30 | Call onResult() with up to five relevant Wikipedia links for this body of text. Put the most relevant links first.
31 |
32 | ${body}
33 | `.trim(),
34 | },
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/parse-course-cip.test.ts:
--------------------------------------------------------------------------------
1 | import { parseCourseCip } from './parse-course-cip'
2 |
3 | describe('parseCourseCip', () => {
4 | it(
5 | 'returns the expected cip code and title for a valid course description',
6 | async () => {
7 | const description =
8 | 'This course covers the fundamentals of computer science, including programming concepts, data structures, and algorithms.'
9 | const result = await parseCourseCip(description)
10 |
11 | expect(result).toMatchInlineSnapshot(`
12 | {
13 | "cipCode": "11.0701",
14 | "cipTitle": "Computer Science",
15 | }
16 | `)
17 | },
18 | {
19 | timeout: 20000,
20 | },
21 | )
22 | })
23 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/parse-course-cip.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { getPredictionForToolResult } from '@/server/lib/anthropic/functions'
4 | import { ChatMessage } from '@/server/lib/anthropic/types'
5 |
6 | const schema = z.object({
7 | cip_code: z.coerce.string().describe("The course's CIP code"),
8 | cip_title: z.string().describe("The course's CIP title"),
9 | })
10 |
11 | export async function parseCourseCip(description: string) {
12 | const result = await getPredictionForToolResult({
13 | messages: getChatMessages(description),
14 | schema,
15 | })
16 |
17 | return {
18 | cipCode: result.cip_code,
19 | cipTitle: result.cip_title,
20 | }
21 | }
22 |
23 | function getChatMessages(description: string): ChatMessage[] {
24 | return [
25 | {
26 | role: 'user',
27 | content: `You are a helpful and accurate assistant.
28 | Parse the Classification of Instructional Programs (CIP) of the university course described below. If you don't know make your best guess.
29 |
30 | Course description: ${description}
31 | `.trim(),
32 | },
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/parse-course-ddc.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { getPredictionForToolResult } from '@/server/lib/anthropic/functions'
4 | import { ChatMessage } from '@/server/lib/anthropic/types'
5 |
6 | const schema = z.object({
7 | ddc_code: z
8 | .string()
9 | .describe("The course's Dewey Decimal Classification code (.e.g. 003)"),
10 | ddc_title: z
11 | .string()
12 | .describe("The course's Dewey Decimal Classification title (.e.g. Systems)"),
13 | })
14 |
15 | export async function parseCourseDeweyDecimalClass(description: string) {
16 | const result = await getPredictionForToolResult({
17 | messages: getChatMessages(description),
18 | schema,
19 | })
20 |
21 | return {
22 | ddcCode: result.ddc_code,
23 | ddcTitle: result.ddc_title,
24 | }
25 | }
26 |
27 | function getChatMessages(description: string): ChatMessage[] {
28 | return [
29 | {
30 | role: 'user',
31 | // content: 'You are a helpful and accurate parsing bot. You parse and process data.',
32 | content: `You are a helpful and accurate assistant.
33 | Parse the Dewey Decimal class (DDC) of the university course described below. If you don't know make your best guess.
34 |
35 | ${description}
36 | `.trim(),
37 | },
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/server/helpers/ai/prompts/parse-course.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { getPredictionForToolResult } from '@/server/lib/anthropic/functions'
4 | import { ChatMessage } from '@/server/lib/anthropic/types'
5 |
6 | const schema = z.object({
7 | outline: z.string().describe('The course outline and objectives'),
8 | targeting: z.string().describe('The target audience'),
9 | headline: z.string().describe('A short one-liner description of the course'),
10 | modules: z.array(
11 | z.object({
12 | week: z.number().describe("The section's week number"),
13 | title: z.string().describe("The section's title"),
14 | units: z.array(
15 | z.object({
16 | number: z
17 | .number()
18 | .describe("The unit's number as a whole integer (1, 2, 3, etc)"),
19 | title: z.string().describe('The lecture title'),
20 | }),
21 | ),
22 | }),
23 | ),
24 | recommendedReading: z.array(
25 | z.object({
26 | title: z.string().describe('The recommended reading title'),
27 | }),
28 | ),
29 | })
30 |
31 | type Parsed = z.infer
32 |
33 | interface ParseCourseOptions {
34 | language?: string
35 | }
36 |
37 | export async function parseCourse(
38 | courseBody: string,
39 | options: ParseCourseOptions = {},
40 | ): Promise {
41 | const result = await getPredictionForToolResult({
42 | schema,
43 | messages: getChatMessages(courseBody, options),
44 | })
45 |
46 | return result
47 | }
48 |
49 | function getChatMessages(
50 | description: string,
51 | { language = 'English' }: ParseCourseOptions,
52 | ): ChatMessage[] {
53 | return [
54 | {
55 | role: 'user',
56 | content: `
57 | You are a parsing bot. You are given a course description and you have to parse it into a course outline.
58 | Here's a course description:
59 |
60 | ${description}
61 |
62 | Use the ${language} language.
63 | Call onResult() with the parsed course.
64 | `.trim(),
65 | },
66 | ]
67 | }
68 |
--------------------------------------------------------------------------------
/server/helpers/api-builder/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api-builder'
2 |
--------------------------------------------------------------------------------
/server/helpers/api-builder/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export interface ApiBuilderPathArgsWithParams {
4 | params?: {
5 | [key: string]: any
6 | }
7 | }
8 |
9 | export type ApiBuilderHandler = (
10 | request: Request,
11 | args: TRequestArgs & {
12 | data: TRequestParams
13 | },
14 | ) => Response | Promise
15 |
16 | export interface ApiBuilderOptions {
17 | schema: z.ZodType
18 | }
19 |
20 | export const optionalDateSchema = z
21 | .string()
22 | .nullable()
23 | .transform((value) => (value === '' ? null : value))
24 | .pipe(z.coerce.date().nullable().default(null))
25 |
--------------------------------------------------------------------------------
/server/helpers/auth/session.ts:
--------------------------------------------------------------------------------
1 | import { createRemoteJWKSet, jwtVerify } from 'jose'
2 |
3 | import { assertString, assert } from '@/lib/assert'
4 |
5 | const hankoApiUrl = process.env.NEXT_PUBLIC_HANKO_API_URL
6 |
7 | interface HankoEmailResponse {
8 | id: string
9 | address: string
10 | is_verified: boolean
11 | is_primary: boolean
12 | }
13 |
14 | const JWKS = createRemoteJWKSet(new URL(`${hankoApiUrl}/.well-known/jwks.json`), {
15 | cooldownDuration: 1000 * 60 * 60 * 24, // 24 hours
16 | cacheMaxAge: 1000 * 60 * 60 * 24, // 24 hours
17 | })
18 |
19 | export async function getUserIdFromSessionToken(token: string) {
20 | const { payload, protectedHeader } = await jwtVerify(token, JWKS)
21 |
22 | assert(protectedHeader.alg === 'RS256', 'alg is not RS256')
23 |
24 | const userId = payload.sub
25 |
26 | assertString(userId, 'userId is not a string')
27 |
28 | return userId
29 | }
30 |
31 | export async function safeGetUserIdFromSessionToken(token: string) {
32 | try {
33 | return await getUserIdFromSessionToken(token)
34 | } catch (error) {
35 | return null
36 | }
37 | }
38 |
39 | export async function getEmailsFromSessionToken(token: string): Promise {
40 | const request = await fetch(`${hankoApiUrl}/emails`, {
41 | headers: {
42 | Authorization: `Bearer ${token}`,
43 | },
44 | next: {
45 | revalidate: 120,
46 | },
47 | })
48 |
49 | if (!request.ok) {
50 | console.error(
51 | 'Failed to fetch emails from Hanko API:',
52 | request.statusText,
53 | await request.text(),
54 | )
55 | return null
56 | }
57 |
58 | const emails = (await request.json()) as HankoEmailResponse[]
59 |
60 | // Sort emails and make sure primary is first
61 | emails.sort((a, b) => (a.is_primary ? -1 : b.is_primary ? 1 : 0))
62 |
63 | return emails.map((email) => email.address)
64 | }
65 |
--------------------------------------------------------------------------------
/server/helpers/auth/token.ts:
--------------------------------------------------------------------------------
1 | type AuthType = 'api' | 'session'
2 | type AuthTypeToken = [AuthType | null, string | null]
3 |
4 | interface HeaderLike {
5 | get: (name: string) => string | null
6 | }
7 |
8 | export function getToken(headers: HeaderLike): AuthTypeToken {
9 | return getTokenFromHeader(headers) || getTokenFromCookie(headers) || [null, null]
10 | }
11 |
12 | function getTokenFromHeader(headers: HeaderLike): AuthTypeToken | null {
13 | const authorization = headers.get('Authorization')
14 |
15 | if (!authorization) {
16 | return null
17 | }
18 |
19 | const [scheme, value] = authorization.split(' ')
20 |
21 | if (scheme === 'Bearer') {
22 | return ['api', value]
23 | }
24 |
25 | if (scheme === 'Basic') {
26 | const decodedValue = decodeBase64(value)
27 | const [token] = decodedValue.split(':')
28 | return ['api', token]
29 | }
30 |
31 | return null
32 | }
33 |
34 | function getTokenFromCookie(
35 | headers: HeaderLike,
36 | cookieName = 'hanko',
37 | ): AuthTypeToken | null {
38 | const cookie = headers.get('Cookie')
39 |
40 | if (!cookie) {
41 | return null
42 | }
43 |
44 | const cookies = cookie.split('; ')
45 |
46 | const tokenCookie = cookies.find((cookie) => cookie.startsWith(`${cookieName}=`))
47 |
48 | if (!tokenCookie) {
49 | return null
50 | }
51 |
52 | const [, token] = tokenCookie.split('=')
53 |
54 | return ['session', token]
55 | }
56 |
57 | function decodeBase64(value: string) {
58 | return Buffer.from(value, 'base64').toString('ascii')
59 | }
60 |
--------------------------------------------------------------------------------
/server/helpers/base-url.ts:
--------------------------------------------------------------------------------
1 | export const baseUrl = process.env.APP_HOST
2 | ? `https://${process.env.APP_HOST}`
3 | : process.env.VERCEL_URL
4 | ? `https://${process.env.VERCEL_URL}`
5 | : 'https://101.school'
6 |
--------------------------------------------------------------------------------
/server/helpers/course-subscription.ts:
--------------------------------------------------------------------------------
1 | import { getCourseSubscriptionByCourseEmail } from '../db/course_subscriptions/getters'
2 | import {
3 | createCourseSubscription,
4 | deleteCourseSubscription,
5 | } from '../db/course_subscriptions/setters'
6 | import { inngest } from '../jobs/client'
7 |
8 | export async function subscribeEmailToCourse({
9 | email,
10 | courseId,
11 | daysInterval = 7,
12 | userId,
13 | }: {
14 | email: string
15 | courseId: string
16 | daysInterval?: number
17 | userId: string | null
18 | }) {
19 | let courseSubscriptionId: string | undefined
20 |
21 | try {
22 | // Create course subscription, if it doesn't exist
23 | courseSubscriptionId = await createCourseSubscription({
24 | userId: userId ?? null,
25 | courseId,
26 | email,
27 | daysInterval,
28 | })
29 | } catch (err: any) {
30 | if (err.message.includes('duplicate')) {
31 | // Already subscribed
32 | return
33 | }
34 |
35 | throw err
36 | }
37 |
38 | // 3. Send course subscription to Inngest
39 | await inngest.send({
40 | id: `course-subscribe-${courseSubscriptionId}`,
41 | name: 'course/subscribe',
42 | data: {
43 | courseSubscriptionId,
44 | },
45 | })
46 | }
47 |
48 | export async function unsubscribeEmailFromCourse({
49 | email,
50 | courseId,
51 | }: {
52 | email: string
53 | courseId: string
54 | }) {
55 | const courseSubscription = await getCourseSubscriptionByCourseEmail({
56 | courseId,
57 | email,
58 | })
59 |
60 | if (courseSubscription) {
61 | await inngest.send({
62 | name: 'course/unsubscribe',
63 | data: {
64 | courseSubscriptionId: courseSubscription.id,
65 | },
66 | })
67 |
68 | await deleteCourseSubscription(courseSubscription.id)
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/server/helpers/error.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 |
3 | export function error(message: string, type = 'invalid_request', status = 400) {
4 | return NextResponse.json(
5 | {
6 | error: { message, type },
7 | },
8 | {
9 | status,
10 | },
11 | )
12 | }
13 |
14 | export function notFound() {
15 | return error('Not found', 'not_found', 404)
16 | }
17 |
--------------------------------------------------------------------------------
/server/helpers/links.ts:
--------------------------------------------------------------------------------
1 | import { getSlug } from './slug'
2 |
3 | export function getPathForCourse(params: { course: { slug: string } }) {
4 | return `/courses/${params.course.slug}`
5 | }
6 |
7 | export function getPathForCourseModule(params: {
8 | course: { slug: string }
9 | courseModule: { number: number; title: string }
10 | }) {
11 | return `/courses/${params.course.slug}/modules/${getSlug(params.courseModule)}`
12 | }
13 |
14 | export function getPathForCourseUnit(params: {
15 | course: { slug: string }
16 | courseModule: { number: number; title: string }
17 | courseUnit: { number: number; title: string }
18 | }) {
19 | return `/courses/${params.course.slug}/modules/${getSlug(
20 | params.courseModule,
21 | )}/units/${getSlug(params.courseUnit)}`
22 | }
23 |
--------------------------------------------------------------------------------
/server/helpers/params-getters.ts:
--------------------------------------------------------------------------------
1 | import { cache } from 'react'
2 |
3 | import { getNumberFromSlug } from './slug'
4 | import { getCourseBySlug } from '../db/courses/getters'
5 | import { getModuleByNumber } from '../db/modules/getters'
6 | import { CourseModule } from '../db/modules/types'
7 | import { getUnitByNumber } from '../db/units/getters'
8 | import { CourseModuleUnit } from '../db/units/types'
9 |
10 | async function getUncachedCourseContext(params: {
11 | courseSlug: string
12 | moduleSlug?: string
13 | unitSlug?: string
14 | }) {
15 | const course = await getCourseBySlug(params.courseSlug)
16 |
17 | if (!course) {
18 | return {
19 | course: null,
20 | courseModule: null,
21 | courseUnit: null,
22 | }
23 | }
24 |
25 | let courseModule: CourseModule | null = null
26 |
27 | if (params.moduleSlug) {
28 | const moduleNumber = getNumberFromSlug(params.moduleSlug)
29 |
30 | if (typeof moduleNumber === 'number') {
31 | courseModule = await getModuleByNumber(course.id, moduleNumber)
32 | }
33 | }
34 |
35 | let courseUnit: CourseModuleUnit | null = null
36 |
37 | if (courseModule && params.unitSlug) {
38 | const unitNumber = getNumberFromSlug(params.unitSlug)
39 |
40 | if (typeof unitNumber === 'number') {
41 | courseUnit = await getUnitByNumber(courseModule.id, unitNumber)
42 | }
43 | }
44 |
45 | return {
46 | course,
47 | courseModule,
48 | courseUnit,
49 | }
50 | }
51 |
52 | export const getCourseContext = cache(getUncachedCourseContext)
53 |
--------------------------------------------------------------------------------
/server/helpers/params.ts:
--------------------------------------------------------------------------------
1 | export const getParams = (request: Request) => {
2 | const uri = new URL(request.url)
3 | return uri.searchParams
4 | }
5 |
--------------------------------------------------------------------------------
/server/helpers/slug.ts:
--------------------------------------------------------------------------------
1 | import { slugify } from '@/lib/slugify'
2 |
3 | export function getNumberFromSlug(slug: string): number | null {
4 | const firstPart = slug.split('-').shift()
5 |
6 | if (!firstPart) {
7 | return null
8 | }
9 |
10 | const number = parseInt(firstPart, 10)
11 |
12 | if (isNaN(number)) {
13 | return null
14 | }
15 |
16 | return number
17 | }
18 |
19 | export function getSlug({ number, title }: { number: number; title: string }) {
20 | return `${number}-${slugify(title)}`
21 | }
22 |
--------------------------------------------------------------------------------
/server/jobs/client.ts:
--------------------------------------------------------------------------------
1 | import { EventSchemas, Inngest } from 'inngest'
2 |
3 | type Events = {
4 | 'test/hello.world': {
5 | data: {}
6 | }
7 |
8 | 'course/generate': {
9 | data: {
10 | courseId: string
11 | }
12 | }
13 |
14 | 'course/subscribe': {
15 | data: {
16 | courseSubscriptionId: string
17 | }
18 | }
19 |
20 | 'course/unsubscribe': {
21 | data: {
22 | courseSubscriptionId: string
23 | }
24 | }
25 | }
26 |
27 | // Create a client to send and receive events
28 | export const inngest = new Inngest({
29 | id: '101-school',
30 | schemas: new EventSchemas().fromRecord(),
31 | })
32 |
--------------------------------------------------------------------------------
/server/jobs/functions/course-generate/index.ts:
--------------------------------------------------------------------------------
1 | export * from './course-generate'
2 |
--------------------------------------------------------------------------------
/server/jobs/functions/course-generate/step-generate-module.ts:
--------------------------------------------------------------------------------
1 | import { Logger } from 'inngest/middleware/logger'
2 |
3 | import { assert } from '@/lib/assert'
4 | import { getCourse } from '@/server/db/courses/getters'
5 | import { setModule } from '@/server/db/modules/setters'
6 | import { CourseParsedModule } from '@/server/db/schema'
7 | import { generateModule } from '@/server/helpers/ai/prompts/generate-module'
8 |
9 | export async function stepGenerateModule({
10 | parsedModule,
11 | courseId,
12 | logger,
13 | }: {
14 | parsedModule: CourseParsedModule
15 | courseId: string
16 | logger: Logger
17 | }) {
18 | const course = await getCourse(courseId)
19 | assert(course, 'Course not found')
20 |
21 | logger.info('Generating module', {
22 | courseId: course.id,
23 | moduleNumber: parsedModule.week,
24 | })
25 | const moduleContent = await generateModule(
26 | {
27 | courseDescription: course.description,
28 | courseBody: course.content,
29 | moduleNumber: parsedModule.week,
30 | },
31 | {
32 | targeting: course.targeting ?? undefined,
33 | weekCount: course.weekCount ?? undefined,
34 | language: course.language ?? undefined,
35 | },
36 | )
37 |
38 | logger.info('Saving module', { courseId: course.id, moduleNumber: parsedModule.week })
39 | await setModule({
40 | courseId: course.id,
41 | title: parsedModule.title,
42 | content: moduleContent,
43 | number: parsedModule.week,
44 | })
45 | }
46 |
--------------------------------------------------------------------------------
/server/jobs/functions/course-generate/step-send-email.ts:
--------------------------------------------------------------------------------
1 | import { renderAsync } from '@react-email/render'
2 |
3 | import { assert } from '@/lib/assert'
4 | import { createEmail } from '@/lib/resend'
5 | import { getCourse } from '@/server/db/courses/getters'
6 | import { getUser } from '@/server/db/users/getters'
7 | import { CourseCreatedEmail } from '@/server/emails/course-created-email'
8 |
9 | export async function stepSendEmail({
10 | courseId,
11 | userId,
12 | }: {
13 | courseId: string
14 | userId: string
15 | }) {
16 | const course = await getCourse(courseId)
17 | const user = await getUser(userId)
18 | const toEmail = user?.emails[0]
19 |
20 | assert(course, `Course ${courseId} not found`)
21 |
22 | if (!toEmail) {
23 | console.warn(`User ${userId} has no email`)
24 | return
25 | }
26 |
27 | const element = CourseCreatedEmail({
28 | userName: user?.name ?? null,
29 | courseSlug: course.slug,
30 | courseTitle: course.title,
31 | courseDescription: course.description,
32 | })
33 |
34 | const html = await renderAsync(element)
35 |
36 | const text = await renderAsync(element, {
37 | plainText: true,
38 | })
39 |
40 | await createEmail({
41 | html,
42 | text,
43 | subject: `Your course on ${course.title} is ready!`,
44 | to: toEmail,
45 | from: 'alex@101.school',
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/server/jobs/functions/course-subscribe/course-subscribe.ts:
--------------------------------------------------------------------------------
1 | import { assert } from '@/lib/assert'
2 | import { getCourseSubscription } from '@/server/db/course_subscriptions/getters'
3 | import { getCourse, getCourseUnits } from '@/server/db/courses/getters'
4 |
5 | import { stepSendEmail } from './step-send-email'
6 | import { inngest } from '../../client'
7 |
8 | export const courseSubscribe = inngest.createFunction(
9 | {
10 | id: 'course-subscribe',
11 | cancelOn: [{ event: 'course/unsubscribe', match: 'data.courseSubscriptionId' }],
12 | },
13 | { event: 'course/subscribe' },
14 | async ({ event, step, logger }) => {
15 | const { courseSubscriptionId } = event.data
16 |
17 | logger.info('Subscribe to course', { courseSubscriptionId })
18 |
19 | const courseSubscription = await step.run('Get course subscription', () =>
20 | getCourseSubscription(courseSubscriptionId),
21 | )
22 |
23 | assert(courseSubscription, 'Course subscription not found')
24 |
25 | const course = await step.run('Get course', () =>
26 | getCourse(courseSubscription.courseId),
27 | )
28 |
29 | assert(course, 'Course not found')
30 |
31 | logger.info(`Processing subscription for: ${course.title}`)
32 |
33 | const courseUnits = await step.run('Get course units', async () =>
34 | getCourseUnits(course.id),
35 | )
36 |
37 | if (courseUnits.length === 0) {
38 | logger.warn('Course has no units')
39 | return
40 | }
41 |
42 | logger.info(`Found ${courseUnits.length} units`)
43 |
44 | for (const unit of courseUnits) {
45 | await step.run(`Send email for unit ${unit.id}`, async () => {
46 | await stepSendEmail({
47 | unitId: unit.id,
48 | courseSubscriptionId,
49 | logger,
50 | })
51 | })
52 |
53 | await step.sleep('wait-a-moment', `${courseSubscription.daysInterval} days`)
54 | }
55 | },
56 | )
57 |
--------------------------------------------------------------------------------
/server/jobs/functions/course-subscribe/index.ts:
--------------------------------------------------------------------------------
1 | export * from './course-subscribe'
2 |
--------------------------------------------------------------------------------
/server/lib/anthropic/client.ts:
--------------------------------------------------------------------------------
1 | import { assertString } from '../../../lib/assert'
2 |
3 | const ANTHROPIC_ENDPOINT = 'https://api.anthropic.com'
4 |
5 | type AnthropicVersions = '2023-01-01' | '2023-06-01'
6 |
7 | type FetchApiOptions = {
8 | method?: 'GET' | 'POST'
9 | body?: Record
10 | apiKey?: string
11 | version?: AnthropicVersions
12 | betaVersion?: string
13 | }
14 |
15 | export async function fetchApi(
16 | path: string,
17 | {
18 | method,
19 | body,
20 | apiKey = getApiKey(),
21 | version = '2023-06-01',
22 | betaVersion = 'tools-2024-04-04',
23 | }: FetchApiOptions,
24 | ) {
25 | assertString(apiKey, 'No Anthropic API Key provided')
26 |
27 | // Log requests to the Anthropic API
28 | console.log(`Anthropic API request: ${method} ${path}`)
29 | console.log('Anthropic API request body:', JSON.stringify(body, null, 2))
30 |
31 | const response = await fetch(`${ANTHROPIC_ENDPOINT}${path}`, {
32 | method,
33 | body: body ? JSON.stringify(body) : undefined,
34 | headers: {
35 | 'x-api-key': apiKey,
36 | 'anthropic-version': version,
37 | ...(betaVersion ? { 'anthropic-beta': betaVersion } : {}),
38 | ...(body ? { 'Content-Type': 'application/json' } : {}),
39 | },
40 | })
41 |
42 | if (!response.ok) {
43 | throw new Error(
44 | `Anthropic API responded with ${response.status}: ${await response.text()}}`,
45 | )
46 | }
47 |
48 | return response
49 | }
50 |
51 | export async function fetchApiJson(path: string, options: FetchApiOptions) {
52 | const response = await fetchApi(path, options)
53 |
54 | const json = await response.json()
55 |
56 | // Log responses from the Anthropic API
57 | console.log('Anthropic API response:', JSON.stringify(json, null, 2))
58 |
59 | return json as T
60 | }
61 |
62 | function getApiKey(): string | undefined {
63 | if (typeof process !== 'undefined') {
64 | return process.env?.ANTHROPIC_API_KEY
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/server/lib/anthropic/completion.test.ts:
--------------------------------------------------------------------------------
1 | import { getPredictedMessages } from './completion'
2 | import { ChatMessage } from './types'
3 |
4 | describe('fetchCompletion', () => {
5 | it('should fetch completion', async () => {
6 | const messages: ChatMessage[] = [
7 | {
8 | role: 'user',
9 | content: 'hello',
10 | },
11 | ]
12 |
13 | const result = await getPredictedMessages({
14 | messages,
15 | temperature: 0.5,
16 | maxTokens: 1024,
17 | })
18 |
19 | expect(result).toMatchObject({
20 | content: [
21 | {
22 | text: 'Hello! How can I assist you today?',
23 | type: 'text',
24 | },
25 | ],
26 | id: expect.any(String),
27 | model: 'claude-3-opus-20240229',
28 | role: 'assistant',
29 | stop_reason: 'end_turn',
30 | stop_sequence: null,
31 | type: 'message',
32 | usage: {
33 | input_tokens: 8,
34 | output_tokens: 12,
35 | },
36 | })
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/server/lib/anthropic/functions.test.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { getPredictionForTool, getPredictionForToolResult } from './functions'
4 | import { ChatMessage } from './types'
5 |
6 | describe('getPredictionForTool', () => {
7 | it(
8 | 'returns a valid response',
9 | async () => {
10 | const schema = z.object({
11 | expression: z.string().describe('The arithmetic expression to evaluate'),
12 | })
13 |
14 | const response = await getPredictionForTool({
15 | toolName: 'calculator',
16 | toolDescription: 'Calculator for basic arithmetic',
17 | schema,
18 | messages: [
19 | {
20 | role: 'user',
21 | content: 'Calculate 2 + 2',
22 | },
23 | ],
24 | })
25 |
26 | expect(response).toBeDefined()
27 | expect(response.expression).toEqual('2 + 2')
28 | },
29 | { timeout: 10000 },
30 | )
31 | })
32 |
33 | describe('getPredictionForToolResult', () => {
34 | it(
35 | 'should return parsed result when valid schema and messages are provided',
36 | async () => {
37 | // Define a test schema
38 | const testSchema = z.object({
39 | name: z.string(),
40 | age: z.number(),
41 | })
42 |
43 | // Define test messages
44 | const testMessages: ChatMessage[] = [
45 | {
46 | role: 'user',
47 | content: 'Users name is John Doe. User is 30.',
48 | },
49 | ]
50 |
51 | // Call the function with test schema and messages
52 | const result = await getPredictionForToolResult({
53 | messages: testMessages,
54 | schema: testSchema,
55 | })
56 |
57 | // Assert the result matches the expected schema
58 | expect(result).toEqual({
59 | name: 'John Doe',
60 | age: 30,
61 | })
62 | },
63 | { timeout: 10000 },
64 | )
65 | })
66 |
--------------------------------------------------------------------------------
/server/lib/anthropic/functions.ts:
--------------------------------------------------------------------------------
1 | import { last } from 'lodash'
2 | import { z } from 'zod'
3 |
4 | import { CompletionOptions, getPredictedMessages } from './completion'
5 | import { ChatMessage, Tool } from './types'
6 | import { zodToFunctionParameters } from '../zod-fns'
7 |
8 | export function buildTool({
9 | name,
10 | description,
11 | schema,
12 | }: {
13 | name: string
14 | description: string
15 | schema: z.ZodSchema
16 | }): Tool {
17 | return {
18 | name,
19 | description,
20 | input_schema: schema
21 | ? zodToFunctionParameters(schema)
22 | : {
23 | type: 'object',
24 | properties: {},
25 | },
26 | }
27 | }
28 |
29 | export async function getPredictionForTool({
30 | toolName,
31 | toolDescription,
32 | schema,
33 | ...completionOptions
34 | }: {
35 | toolName: string
36 | toolDescription: string
37 | schema: z.ZodSchema
38 | } & CompletionOptions): Promise> {
39 | const tools: Tool[] = [
40 | buildTool({
41 | name: toolName,
42 | description: toolDescription,
43 | schema,
44 | }),
45 | ]
46 |
47 | const response = await getPredictedMessages({ tools, ...completionOptions })
48 |
49 | const message = last(response.content)
50 |
51 | if (message?.type !== 'tool_use') {
52 | throw new Error('Expected a tool use message')
53 | }
54 |
55 | if (message.name !== toolName) {
56 | throw new Error(`Expected tool name to be ${toolName}, got ${message.name}`)
57 | }
58 |
59 | return schema.parse(message.input)
60 | }
61 |
62 | /*
63 | * Fetch completion for a single tool (onResult) and return the parsed parameters
64 | */
65 | export async function getPredictionForToolResult({
66 | messages,
67 | schema,
68 | }: {
69 | messages: ChatMessage[]
70 | schema: z.ZodSchema
71 | }) {
72 | return await getPredictionForTool({
73 | toolName: 'onResult',
74 | toolDescription: 'The function to call when the result is ready',
75 | schema,
76 | messages,
77 | })
78 | }
79 |
--------------------------------------------------------------------------------
/server/lib/anthropic/types.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchema7 } from 'json-schema'
2 |
3 | export type SupportedModels =
4 | | 'claude-3-opus-20240229'
5 | | 'claude-3-sonnet-20240229'
6 | | 'claude-3-haiku-20240307'
7 |
8 | export interface ChatMessageContentText {
9 | type: 'text'
10 | text: string
11 | }
12 |
13 | export interface ChatMessageContentImage {
14 | type: 'image'
15 | source: {
16 | type: 'base64'
17 | media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'
18 | data: string
19 | }
20 | }
21 |
22 | export interface ChatMessageContentToolUse {
23 | type: 'tool_use'
24 | id: string
25 | name: string
26 | input: unknown
27 | }
28 |
29 | export type ChatMessageContent =
30 | | ChatMessageContentText
31 | | ChatMessageContentImage
32 | | ChatMessageContentToolUse
33 |
34 | export interface ChatMessage {
35 | role: 'user' | 'assistant'
36 | content: string | ChatMessageContent[]
37 | }
38 |
39 | export interface UsageInfo {
40 | input_tokens: number
41 | output_tokens: number
42 | }
43 |
44 | export interface MessageResponse {
45 | id: string
46 | type: 'message'
47 | role: 'assistant'
48 | content: ChatMessageContent[]
49 | model: string
50 | stop_reason: 'end_turn' | 'max_tokens' | 'stop_sequence'
51 | stop_sequence: string | null
52 | usage: UsageInfo
53 | }
54 |
55 | export interface Tool {
56 | name: string
57 | description: string
58 | input_schema: JSONSchema7
59 | }
60 |
61 | export interface FunctionCall {
62 | tool_name: string
63 | parameters?: {
64 | [key: string]: string
65 | }
66 | }
67 |
68 | export interface FunctionCallResult {
69 | tool_name: string
70 | stdout?: string
71 | error?: string
72 | }
73 |
--------------------------------------------------------------------------------
/server/lib/id.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet } from 'nanoid'
2 |
3 | export const nanoid = customAlphabet(
4 | '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
5 | 7,
6 | ) // 7-character random string
7 |
--------------------------------------------------------------------------------
/server/lib/open-ai/client.ts:
--------------------------------------------------------------------------------
1 | import { assertString } from '../../../lib/assert'
2 |
3 | const OPENAI_ENDPOINT = 'https://api.openai.com/v1'
4 |
5 | export async function fetchApi(
6 | path: string,
7 | {
8 | method,
9 | body,
10 | apiKey = getApiKey(),
11 | }: {
12 | method?: 'GET' | 'POST'
13 | body?: Record
14 | apiKey?: string
15 | },
16 | ): Promise {
17 | assertString(apiKey, 'No OpenAPI API Key provided')
18 |
19 | const response = await fetch(`${OPENAI_ENDPOINT}${path}`, {
20 | method,
21 | body: body ? JSON.stringify(body) : undefined,
22 | headers: {
23 | Authorization: `Bearer ${apiKey}`,
24 | ...(body ? { 'Content-Type': 'application/json' } : {}),
25 | },
26 | })
27 |
28 | if (!response.ok) {
29 | throw new Error(
30 | `OpenAI API responded with ${response.status}: ${await response.text()}}`,
31 | )
32 | }
33 |
34 | const json = await response.json()
35 |
36 | return json as T
37 | }
38 |
39 | function getApiKey(): string | undefined {
40 | if (typeof process !== 'undefined') {
41 | return process.env?.OPENAI_API_KEY
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/server/lib/open-ai/completion.ts:
--------------------------------------------------------------------------------
1 | import { fetchApi } from './client'
2 | import {
3 | ChatFunction,
4 | ChatMessage,
5 | ChatResponse,
6 | OpenAIChatCompletion,
7 | SupportedModels,
8 | } from './types'
9 | import { assert } from '../../../lib/assert'
10 |
11 | interface Options {
12 | messages: ChatMessage[]
13 | model?: SupportedModels
14 | temperature?: number
15 | apiKey?: string
16 | functions?: ChatFunction[]
17 | functionCall?: string
18 | }
19 |
20 | export async function fetchCompletion({
21 | messages,
22 | functions,
23 | functionCall,
24 | apiKey,
25 | model = 'gpt-4-1106-preview',
26 | temperature = 0,
27 | }: Options): Promise {
28 | const response = await fetchApi(`/chat/completions`, {
29 | method: 'POST',
30 | body: {
31 | model,
32 | temperature,
33 | messages,
34 | functions,
35 | function_call: functionCall ? { name: functionCall } : undefined,
36 | },
37 | apiKey,
38 | })
39 |
40 | assert(response.choices.length === 1, 'Expected response.choices to be 1')
41 |
42 | const [choice] = response.choices
43 |
44 | return choice.message
45 | }
46 |
--------------------------------------------------------------------------------
/server/lib/open-ai/functions.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | import { ChatFunction } from './types'
4 | import { zodToFunctionParameters } from '../zod-fns'
5 |
6 | export function buildChatFunction({
7 | name,
8 | description,
9 | schema,
10 | }: {
11 | name: string
12 | description: string
13 | schema: z.AnyZodObject
14 | }): ChatFunction {
15 | return {
16 | name,
17 | description,
18 | parameters: schema
19 | ? zodToFunctionParameters(schema)
20 | : {
21 | type: 'object',
22 | properties: {},
23 | },
24 | }
25 | }
26 |
27 | export function parseChatFunctionArgs(
28 | args: string,
29 | schema: T,
30 | ): z.infer {
31 | const parsed = JSON.parse(args)
32 | return schema.parse(parsed)
33 | }
34 |
--------------------------------------------------------------------------------
/server/lib/open-ai/index.ts:
--------------------------------------------------------------------------------
1 | export * from './completion'
2 |
--------------------------------------------------------------------------------
/server/lib/open-ai/types.ts:
--------------------------------------------------------------------------------
1 | import { JSONSchema7 } from 'json-schema'
2 |
3 | export type SupportedModels = 'gpt-4' | 'gpt-3.5-turbo' | 'gpt-4-1106-preview'
4 |
5 | type ChatRole = 'user' | 'system' | 'assistant'
6 |
7 | type JsonString = string
8 |
9 | export interface OpenAIChatCompletionMessage {
10 | role: ChatRole
11 | content: string
12 | function_call?: {
13 | name: string
14 | arguments: JsonString
15 | }
16 | }
17 |
18 | export interface OpenAIChatCompletionChoice {
19 | message: OpenAIChatCompletionMessage
20 | finish_reason: string
21 | index: number
22 | }
23 |
24 | export interface OpenAIChatCompletion {
25 | id: string
26 | object: string
27 | created: number
28 | model: string
29 | usage: {
30 | prompt_tokens: number
31 | completion_tokens: number
32 | total_tokens: number
33 | }
34 | choices: OpenAIChatCompletionChoice[]
35 | }
36 |
37 | export type ChatMessageRole = 'user' | 'system' | 'assistant' | 'function'
38 |
39 | export interface ChatFunctionCall {
40 | name: string
41 | arguments: string
42 | }
43 |
44 | export interface ChatFunction {
45 | name: string
46 | description: string
47 | parameters: JSONSchema7
48 | }
49 |
50 | export interface ChatMessage {
51 | role: ChatMessageRole
52 | content?: string
53 | name?: string
54 | function_call?: ChatFunctionCall
55 | }
56 |
57 | export interface ChatRequest {
58 | messages: ChatMessage[]
59 | functions?: ChatFunction[]
60 | functionCall?: string
61 | }
62 |
63 | export type ChatResponse = ChatMessage
64 |
--------------------------------------------------------------------------------
/server/lib/response-json.ts:
--------------------------------------------------------------------------------
1 | export async function safeJson(response: Response) {
2 | try {
3 | return await response.json()
4 | } catch (error) {
5 | console.error(error)
6 | return null
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/lib/zod-fns.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 | import { zodToJsonSchema } from 'zod-to-json-schema'
3 |
4 | export function zodToFunctionParameters(schema: z.ZodTypeAny) {
5 | const jsonSchema = zodToJsonSchema(schema, 'mySchema')
6 | return jsonSchema.definitions!.mySchema
7 | }
8 |
--------------------------------------------------------------------------------
/server/payments/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from 'stripe'
2 |
3 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
4 | // https://github.com/stripe/stripe-node#configuration
5 | apiVersion: '2024-04-10',
6 | })
7 |
8 | export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "types": ["vitest/globals", "node"],
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "emitDecoratorMetadata": true,
18 | "experimentalDecorators": true,
19 | "incremental": true,
20 | "paths": {
21 | "@/*": ["./*"]
22 | },
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ]
28 | },
29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
30 | "exclude": ["node_modules"]
31 | }
32 |
--------------------------------------------------------------------------------
/types/custom-event.d.ts:
--------------------------------------------------------------------------------
1 | interface WindowEventHandlersEventMap {
2 | 'command.dialog.open': CustomEvent<{}>
3 | 'image.dialog.open': CustomEvent<{
4 | source: string
5 | alt?: string
6 | }>
7 | }
8 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 |
4 | export default defineConfig(() => {
5 | return {
6 | mode: 'test',
7 | plugins: [react()],
8 |
9 | // All environment variable prefixes that should be exposed to the testing environment.
10 | envPrefix: ['NEXT_PUBLIC_', 'VITE_PUBLIC_', 'ANTHROPIC_'],
11 |
12 | test: {
13 | // environment: 'jsdom',
14 |
15 | alias: {
16 | app: '/app',
17 | '@/app': '/app',
18 | lib: '/lib',
19 | '@/lib': '/lib',
20 | server: '/server',
21 | '@/server': '/server',
22 | components: '/components',
23 | '@/components': '/components',
24 | },
25 | globals: true,
26 | },
27 | }
28 | })
29 |
--------------------------------------------------------------------------------