├── .nvmrc
├── drizzle
├── migrate
│ ├── .gitignore
│ ├── package.json
│ └── migrate.ts
├── 0004_careful_jamie_braddock.sql
├── 0009_wild_maria_hill.sql
├── 0008_fat_thena.sql
├── 0002_neat_mole_man.sql
├── 0003_lively_invisible_woman.sql
├── 0001_military_silver_samurai.sql
├── 0013_black_vargas.sql
├── 0012_unusual_shiver_man.sql
├── 0006_right_sunfire.sql
├── 0005_puzzling_random.sql
├── 0007_loose_maximus.sql
├── 0010_red_maestro.sql
├── 0011_clear_christian_walker.sql
├── 0000_complex_harrier.sql
└── meta
│ └── _journal.json
├── src
├── components
│ ├── course-bookmark-button.tsx
│ ├── ui
│ │ ├── skeleton.tsx
│ │ ├── stat.tsx
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── progress.tsx
│ │ ├── separator.tsx
│ │ ├── toaster.tsx
│ │ ├── checkbox.tsx
│ │ ├── switch.tsx
│ │ ├── badge.tsx
│ │ ├── tooltip.tsx
│ │ ├── popover.tsx
│ │ ├── card.tsx
│ │ ├── combobox.tsx
│ │ └── button.tsx
│ ├── title.tsx
│ ├── AppSidebar.tsx
│ ├── NotFound.tsx
│ ├── ModeToggle.tsx
│ ├── DefaultCatchBoundary.tsx
│ └── ThemeProvider.tsx
├── utils
│ ├── uuid.ts
│ ├── env-public.tsx
│ ├── storage
│ │ ├── index.ts
│ │ ├── storage.interface.ts
│ │ ├── helpers.ts
│ │ └── r2.ts
│ ├── local-storage.tsx
│ ├── env.ts
│ ├── users.tsx
│ ├── seo.ts
│ ├── video-duration.ts
│ ├── posts.tsx
│ └── session.ts
├── lib
│ ├── stripe.ts
│ ├── queries
│ │ └── comments.ts
│ ├── utils.ts
│ └── auth.ts
├── use-cases
│ ├── accounts.ts
│ ├── sessions.ts
│ ├── testimonials.ts
│ ├── progress.ts
│ ├── types.ts
│ ├── attachments.ts
│ ├── errors.ts
│ ├── util.ts
│ ├── modules.ts
│ ├── users.ts
│ └── segments.ts
├── routes
│ ├── learn
│ │ ├── -components
│ │ │ ├── add-segment
│ │ │ │ ├── index.ts
│ │ │ │ ├── add-segment-header.tsx
│ │ │ │ ├── server-functions.ts
│ │ │ │ └── use-add-segment.ts
│ │ │ ├── edit-segment
│ │ │ │ ├── index.ts
│ │ │ │ ├── server-functions.ts
│ │ │ │ ├── use-edit-segment.ts
│ │ │ │ └── edit-segment-header.tsx
│ │ │ ├── markdown-content.tsx
│ │ │ ├── container.tsx
│ │ │ ├── segment-context.tsx
│ │ │ ├── assignment-viewer.tsx
│ │ │ ├── navigation.tsx
│ │ │ ├── mobile-navigation-skeleton.tsx
│ │ │ ├── navigation-skeleton.tsx
│ │ │ ├── course-segments.tsx
│ │ │ ├── user-menu.tsx
│ │ │ ├── mobile-navigation.tsx
│ │ │ ├── video-player.tsx
│ │ │ ├── delete-module-button.tsx
│ │ │ └── new-module-button.tsx
│ │ ├── not-found.tsx
│ │ ├── $slug
│ │ │ ├── -components
│ │ │ │ ├── admin-controls.tsx
│ │ │ │ ├── edit-video-button.tsx
│ │ │ │ ├── feedback-button.tsx
│ │ │ │ ├── content-panel.tsx
│ │ │ │ ├── video-header.tsx
│ │ │ │ └── video-content-tabs-panel.tsx
│ │ │ └── edit.tsx
│ │ ├── no-segments.tsx
│ │ ├── add.tsx
│ │ └── index.tsx
│ ├── login.tsx
│ ├── about.tsx
│ ├── api
│ │ ├── logout.ts
│ │ ├── login
│ │ │ └── google
│ │ │ │ ├── index.ts
│ │ │ │ └── callback
│ │ │ │ └── index.ts
│ │ ├── stripe
│ │ │ └── webhook.ts
│ │ └── segments
│ │ │ └── $segmentId
│ │ │ └── video.ts
│ ├── unauthorized.tsx
│ ├── cancel.tsx
│ ├── unauthenticated.tsx
│ ├── index.tsx
│ ├── success.tsx
│ └── -components
│ │ ├── footer.tsx
│ │ └── newsletter.tsx
├── server.ts
├── hooks
│ ├── use-profile.ts
│ ├── use-debounce.ts
│ ├── use-first-segment.ts
│ ├── use-auth.tsx
│ ├── use-window-size.ts
│ ├── use-prevent-tab-close.ts
│ ├── use-mobile.tsx
│ ├── use-continue-slug.ts
│ ├── mutations
│ │ ├── use-delete-comment.ts
│ │ ├── use-edit-comment.ts
│ │ └── use-create-comment.ts
│ └── use-newsletter-subscription.ts
├── db
│ ├── migrate.ts
│ ├── index.ts
│ ├── clear.ts
│ └── seed.ts
├── data-access
│ ├── attachments.ts
│ ├── sessions.ts
│ ├── testimonials.ts
│ ├── accounts.ts
│ ├── progress.ts
│ ├── profiles.ts
│ ├── comments.ts
│ ├── users.ts
│ ├── modules.ts
│ └── segments.ts
├── client.tsx
├── fn
│ ├── modules.ts
│ ├── users.ts
│ ├── storage.ts
│ ├── segments.ts
│ ├── auth.ts
│ └── comments.ts
├── router.tsx
└── styles
│ └── animations.css
├── .prettierignore
├── public
├── graph.png
├── icon.png
├── logo.png
├── favicon.ico
├── favicon.png
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── android-chrome-192x192.png
├── android-chrome-512x512.png
└── site.webmanifest
├── logs.sh
├── Caddyfile
├── tunnel.sh
├── scripts
└── backup-videos.sh
├── setup.sh
├── update.sh
├── .vscode
└── settings.json
├── drizzle.config.ts
├── docker-compose.yml
├── .gitignore
├── Dockerfile
├── components.json
├── .github
└── workflows
│ └── deploy.yml
├── source.sh
├── tsconfig.json
├── vite.config.ts
├── .env.sample
├── LICENSE
├── docker-compose.prod.yml
├── TODO.md
├── README.md
└── package.json
/.nvmrc:
--------------------------------------------------------------------------------
1 | 22
2 |
--------------------------------------------------------------------------------
/drizzle/migrate/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/src/components/course-bookmark-button.tsx:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | **/build
2 | **/public
3 | pnpm-lock.yaml
4 | routeTree.gen.ts
--------------------------------------------------------------------------------
/drizzle/0004_careful_jamie_braddock.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_segment" ADD COLUMN "length" text;
--------------------------------------------------------------------------------
/drizzle/0009_wild_maria_hill.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_segment" ALTER COLUMN "content" DROP NOT NULL;
--------------------------------------------------------------------------------
/drizzle/0008_fat_thena.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_user" ADD COLUMN "isAdmin" boolean DEFAULT false NOT NULL;
--------------------------------------------------------------------------------
/public/graph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/graph.png
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/icon.png
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/logo.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/drizzle/0002_neat_mole_man.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_segment" ADD COLUMN "isPremium" boolean DEFAULT false NOT NULL;
--------------------------------------------------------------------------------
/logs.sh:
--------------------------------------------------------------------------------
1 | #! /bin/bash
2 |
3 | docker compose -f docker-compose.prod.yml logs wdc-tanstack-starter-kit-app --follow
--------------------------------------------------------------------------------
/drizzle/0003_lively_invisible_woman.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_user" ADD COLUMN "isPremium" boolean DEFAULT false NOT NULL;
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | beginner-react-challenges.webdevcody.com {
2 | reverse_proxy wdc-tanstack-starter-kit-app:3000
3 | }
4 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/webdevcody/tanstack-course-platform/HEAD/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/src/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from "uuid";
2 |
3 | export function generateRandomUUID() {
4 | return uuidv4();
5 | }
6 |
--------------------------------------------------------------------------------
/tunnel.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | ssh -L 5435:localhost:5432 root@$COURSE_SERVER_IP
4 | # ssh -L 3000:localhost:3000 root@$COURSE_SERVER_IP
--------------------------------------------------------------------------------
/scripts/backup-videos.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | scp -r -p -C root@$COURSE_SERVER_IP:/var/lib/docker/volumes/wdc-tanstack-starter-kit_app_files/_data/* ./backups/videos
4 |
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sudo apt-get install -y curl
4 | curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
5 | sudo -E bash nodesource_setup.sh
6 |
--------------------------------------------------------------------------------
/drizzle/0001_military_silver_samurai.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_segment" ADD COLUMN "slug" text NOT NULL;--> statement-breakpoint
2 | CREATE INDEX "segments_slug_idx" ON "app_segment" USING btree ("slug");
--------------------------------------------------------------------------------
/src/lib/stripe.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import { env } from "~/utils/env";
3 |
4 | export const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
5 | apiVersion: "2025-02-24.acacia",
6 | });
7 |
--------------------------------------------------------------------------------
/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "Pulling latest changes"
4 | git pull
5 |
6 | echo "Building docker images"
7 | docker compose -f docker-compose.prod.yml up -d --build
8 |
9 | echo "Done"
10 |
--------------------------------------------------------------------------------
/src/use-cases/accounts.ts:
--------------------------------------------------------------------------------
1 | import { getAccountByGoogleId } from "~/data-access/accounts";
2 |
3 | export async function getAccountByGoogleIdUseCase(googleId: string) {
4 | return await getAccountByGoogleId(googleId);
5 | }
6 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/add-segment/index.ts:
--------------------------------------------------------------------------------
1 | export { AddSegmentHeader } from "./add-segment-header";
2 | export { useAddSegment } from "./use-add-segment";
3 | export { createSegmentFn, getUniqueModuleNamesFn } from "./server-functions";
4 |
--------------------------------------------------------------------------------
/src/use-cases/sessions.ts:
--------------------------------------------------------------------------------
1 | import { deleteSessionForUser } from "~/data-access/sessions";
2 | import { UserSession } from "./types";
3 |
4 | export async function invalidateSessionsUseCase(user: UserSession) {
5 | await deleteSessionForUser(user.id);
6 | }
7 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createStartHandler,
3 | defaultStreamHandler,
4 | } from "@tanstack/react-start/server";
5 | import { createRouter } from "./router";
6 |
7 | export default createStartHandler({
8 | createRouter,
9 | })(defaultStreamHandler);
10 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/src/hooks/use-profile.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { getUserProfileFn } from "~/fn/users";
3 |
4 | export function useProfile() {
5 | return useQuery({
6 | queryKey: ["profile"],
7 | queryFn: getUserProfileFn,
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/edit-segment/index.ts:
--------------------------------------------------------------------------------
1 | export { EditSegmentHeader } from "./edit-segment-header";
2 | export { useEditSegment } from "./use-edit-segment";
3 | export {
4 | updateSegmentFn,
5 | getSegmentFn,
6 | getUniqueModuleNamesFn,
7 | } from "./server-functions";
8 |
--------------------------------------------------------------------------------
/drizzle/0013_black_vargas.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_comment" ADD COLUMN "repliedToId" integer;--> statement-breakpoint
2 | ALTER TABLE "app_comment" ADD CONSTRAINT "app_comment_repliedToId_app_user_id_fk" FOREIGN KEY ("repliedToId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/drizzle/0012_unusual_shiver_man.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE "app_comment" ADD COLUMN "parentId" integer;--> statement-breakpoint
2 | ALTER TABLE "app_comment" ADD CONSTRAINT "app_comment_parentId_app_comment_id_fk" FOREIGN KEY ("parentId") REFERENCES "public"."app_comment"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/src/db/migrate.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 |
3 | import { migrate } from "drizzle-orm/postgres-js/migrator";
4 | import { database, pool } from "./index";
5 |
6 | async function main() {
7 | await migrate(database, { migrationsFolder: "drizzle" });
8 | await pool.end();
9 | }
10 |
11 | main();
12 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib",
3 | "files.readonlyInclude": {
4 | "**/routeTree.gen.ts": true
5 | },
6 | "files.watcherExclude": {
7 | "**/routeTree.gen.ts": true
8 | },
9 | "search.exclude": {
10 | "**/routeTree.gen.ts": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/data-access/attachments.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { database } from "~/db";
3 | import { attachments } from "~/db/schema";
4 |
5 | export function getAttachment(attachmentId: number) {
6 | return database.query.attachments.findFirst({
7 | where: eq(attachments.id, attachmentId),
8 | });
9 | }
10 |
--------------------------------------------------------------------------------
/src/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/login")({
4 | component: LoginPage,
5 | });
6 |
7 | function LoginPage() {
8 | return (
9 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/db/index.ts:
--------------------------------------------------------------------------------
1 | import { env } from "~/utils/env";
2 | import * as schema from "./schema";
3 | import { drizzle } from "drizzle-orm/node-postgres";
4 | import pg from "pg";
5 |
6 | const pool = new pg.Pool({ connectionString: env.DATABASE_URL });
7 | const database = drizzle(pool, { schema });
8 |
9 | export { database, pool };
10 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "drizzle-kit";
2 | import { env } from "~/utils/env";
3 |
4 | export default defineConfig({
5 | schema: "./src/db/schema.ts",
6 | dialect: "postgresql",
7 | out: "./drizzle",
8 | dbCredentials: {
9 | url: env.DATABASE_URL,
10 | },
11 | verbose: true,
12 | strict: true,
13 | });
14 |
--------------------------------------------------------------------------------
/src/data-access/sessions.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import { sessions } from "~/db/schema";
3 | import { UserId } from "~/use-cases/types";
4 | import { eq } from "drizzle-orm";
5 |
6 | export async function deleteSessionForUser(userId: UserId, trx = database) {
7 | await trx.delete(sessions).where(eq(sessions.userId, userId));
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/env-public.tsx:
--------------------------------------------------------------------------------
1 | // This has to live in a separate file because import.meta breaks when running migrations
2 |
3 | export const publicEnv = {
4 | VITE_FILE_URL: import.meta.env.VITE_FILE_URL!,
5 | VITE_STRIPE_PUBLISHABLE_KEY: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!,
6 | VITE_RECAPTCHA_KEY: import.meta.env.VITE_RECAPTCHA_KEY!,
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "~/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/title.tsx:
--------------------------------------------------------------------------------
1 | export function Title({
2 | title,
3 | actions,
4 | }: {
5 | title: React.ReactNode;
6 | actions?: React.ReactNode;
7 | }) {
8 | return (
9 |
10 |
{title}
11 |
12 | {actions}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/client.tsx:
--------------------------------------------------------------------------------
1 | import { StartClient } from "@tanstack/react-start";
2 | import { StrictMode } from "react";
3 | import { hydrateRoot } from "react-dom/client";
4 | import { createRouter } from "./router";
5 |
6 | const router = createRouter();
7 |
8 | hydrateRoot(
9 | document,
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/src/lib/queries/comments.ts:
--------------------------------------------------------------------------------
1 | import { queryOptions } from "@tanstack/react-query";
2 | import { getCommentsFn } from "~/fn/comments";
3 |
4 | export const getCommentsQuery = (segmentId: number) =>
5 | queryOptions({
6 | queryKey: ["comments", segmentId] as const,
7 | queryFn: ({ queryKey: [, sId] }) =>
8 | getCommentsFn({ data: { segmentId: sId } }),
9 | });
10 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/markdown-content.tsx:
--------------------------------------------------------------------------------
1 | import ReactMarkdown from "react-markdown";
2 |
3 | interface MarkdownContentProps {
4 | content: string;
5 | }
6 |
7 | export function MarkdownContent({ content }: MarkdownContentProps) {
8 | return (
9 |
10 | {content}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/use-cases/testimonials.ts:
--------------------------------------------------------------------------------
1 | import { createTestimonial, getTestimonials } from "~/data-access/testimonials";
2 | import { type TestimonialCreate } from "~/db/schema";
3 |
4 | export async function createTestimonialUseCase(data: TestimonialCreate) {
5 | return await createTestimonial(data);
6 | }
7 |
8 | export async function getTestimonialsUseCase() {
9 | return await getTestimonials();
10 | }
11 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | wdc-tanstack-starter-kit-db:
4 | image: postgres:17
5 | restart: always
6 | container_name: wdc-tanstack-starter-kit-db
7 | ports:
8 | - 5432:5432
9 | environment:
10 | POSTGRES_PASSWORD: example
11 | PGDATA: /data/postgres
12 | volumes:
13 | - postgres:/data/postgres
14 |
15 | volumes:
16 | postgres:
17 |
--------------------------------------------------------------------------------
/drizzle/migrate/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Used for installing and running the migration on the Dockerfile runner.",
3 | "license": "MIT",
4 | "scripts": {
5 | "db:migrate": "tsx ./migrate.ts"
6 | },
7 | "dependencies": {
8 | "dotenv": "16.4.5",
9 | "drizzle-orm": "0.32.1",
10 | "postgres": "3.4.4",
11 | "tsx": "^4.16.2",
12 | "typescript": "5.5.4"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/storage/index.ts:
--------------------------------------------------------------------------------
1 | import type { IStorage } from "./storage.interface";
2 | import { R2Storage } from "./r2";
3 |
4 | let storage: IStorage | null = null;
5 |
6 | // Storage provider factory/singleton - R2 only
7 | export function getStorage(): { storage: IStorage; type: "r2" } {
8 | if (!storage) {
9 | storage = new R2Storage();
10 | }
11 |
12 | return { storage, type: "r2" };
13 | }
14 |
--------------------------------------------------------------------------------
/drizzle/0006_right_sunfire.sql:
--------------------------------------------------------------------------------
1 | DROP INDEX "progress_user_id_segment_id_idx";--> statement-breakpoint
2 | ALTER TABLE "app_progress" ADD CONSTRAINT "app_progress_segmentId_app_segment_id_fk" FOREIGN KEY ("segmentId") REFERENCES "public"."app_segment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
3 | CREATE UNIQUE INDEX "progress_user_segment_unique_idx" ON "app_progress" USING btree ("userId","segmentId");
--------------------------------------------------------------------------------
/src/hooks/use-debounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const timer = setTimeout(() => setDebouncedValue(value), delay);
8 | return () => clearTimeout(timer);
9 | }, [value, delay]);
10 |
11 | return debouncedValue;
12 | }
--------------------------------------------------------------------------------
/src/routes/about.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/about")({
4 | component: RouteComponent,
5 | });
6 |
7 | function RouteComponent() {
8 | return (
9 |
10 |
About
11 |
12 | Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package-lock.json
3 | yarn.lock
4 |
5 | .DS_Store
6 | .cache
7 | .env
8 | .env.postgres
9 | .vercel
10 | .output
11 | .vinxi
12 |
13 | /build/
14 | /api/
15 | /server/build
16 | /public/build
17 | .vinxi
18 | # Sentry Config File
19 | .env.sentry-build-plugin
20 | /test-results/
21 | /playwright-report/
22 | /blob-report/
23 | /playwright/.cache/
24 | files/*
25 | backups/*
26 | .nitro/
27 | .tanstack/
--------------------------------------------------------------------------------
/src/components/ui/stat.tsx:
--------------------------------------------------------------------------------
1 | import { type ReactNode } from "react";
2 |
3 | interface StatProps {
4 | label: string;
5 | value: string;
6 | }
7 |
8 | export function Stat({ label, value }: StatProps) {
9 | return (
10 |
11 |
{value}
12 |
{label}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/AppSidebar.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Sidebar,
3 | SidebarContent,
4 | SidebarFooter,
5 | SidebarGroup,
6 | SidebarHeader,
7 | } from "~/components/ui/sidebar";
8 |
9 | export function AppSidebar() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/local-storage.tsx:
--------------------------------------------------------------------------------
1 | const LAST_WATCHED_SEGMENT_KEY = "lastWatchedSegment";
2 |
3 | export function setLastWatchedSegment(slug: string) {
4 | if (typeof window !== "undefined") {
5 | localStorage.setItem(LAST_WATCHED_SEGMENT_KEY, slug);
6 | }
7 | }
8 |
9 | export function getLastWatchedSegment(): string | null {
10 | if (typeof window !== "undefined") {
11 | return localStorage.getItem(LAST_WATCHED_SEGMENT_KEY);
12 | }
13 | return null;
14 | }
15 |
--------------------------------------------------------------------------------
/src/fn/modules.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { adminMiddleware } from "~/lib/auth";
3 | import { z } from "zod";
4 | import { reorderModulesUseCase } from "~/use-cases/modules";
5 |
6 | export const reorderModulesFn = createServerFn()
7 | .middleware([adminMiddleware])
8 | .validator(z.array(z.object({ id: z.number(), order: z.number() })))
9 | .handler(async ({ data }) => {
10 | return reorderModulesUseCase(data);
11 | });
12 |
--------------------------------------------------------------------------------
/src/fn/users.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { unauthenticatedMiddleware } from "~/lib/auth";
3 | import { getProfile } from "~/data-access/profiles";
4 |
5 | export const getUserProfileFn = createServerFn({
6 | method: "GET",
7 | })
8 | .middleware([unauthenticatedMiddleware])
9 | .handler(async ({ context }) => {
10 | if (!context.userId) {
11 | return null;
12 | }
13 | return getProfile(context.userId);
14 | });
15 |
--------------------------------------------------------------------------------
/src/hooks/use-first-segment.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { createServerFn } from "@tanstack/react-start";
3 | import { getSegments } from "~/data-access/segments";
4 |
5 | const getFirstSegmentFn = createServerFn().handler(async () => {
6 | const segments = await getSegments();
7 | return segments[0];
8 | });
9 |
10 | export function useFirstSegment() {
11 | return useQuery({ queryKey: ["first-segment"], queryFn: getFirstSegmentFn });
12 | }
13 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use Node.js 20 LTS as the base image
2 | FROM node:22-alpine
3 |
4 | # Set working directory
5 | WORKDIR /app
6 |
7 | # Copy package files
8 | COPY package*.json ./
9 |
10 | # Install dependencies
11 | RUN npm ci
12 |
13 | # Copy the rest of the application code
14 | COPY . .
15 |
16 | # Build the application
17 | RUN npm run build
18 |
19 | # Expose the port your app runs on (adjust if needed)
20 | EXPOSE 3000
21 |
22 | # Start the application
23 | CMD ["npm", "start"]
24 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "",
8 | "css": "src/styles/app.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils",
16 | "ui": "~/components/ui",
17 | "lib": "~/lib",
18 | "hooks": "~/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/src/hooks/use-auth.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { createServerFn } from "@tanstack/react-start";
3 | import { getCurrentUser } from "~/utils/session";
4 |
5 | export const getUserInfoFn = createServerFn().handler(async () => {
6 | const user = await getCurrentUser();
7 | return { user };
8 | });
9 |
10 | export function useAuth() {
11 | const userInfo = useQuery({
12 | queryKey: ["userInfo"],
13 | queryFn: () => getUserInfoFn(),
14 | });
15 |
16 | return userInfo.data?.user;
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Remote Deploy
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | deploy:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: SSH and Run Commands
14 | uses: appleboy/ssh-action@master
15 | with:
16 | host: ${{ secrets.SSH_HOST }}
17 | username: ${{ secrets.SSH_USER }}
18 | key: ${{ secrets.SSH_PRIVATE_KEY }}
19 | script: |
20 | cd /root/wdc-tanstack-starter-kit
21 | ./update.sh
22 |
--------------------------------------------------------------------------------
/drizzle/0005_puzzling_random.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "app_progress" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "userId" serial NOT NULL,
4 | "segmentId" serial NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL
6 | );
7 | --> statement-breakpoint
8 | ALTER TABLE "app_progress" ADD CONSTRAINT "app_progress_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
9 | CREATE INDEX "progress_user_id_segment_id_idx" ON "app_progress" USING btree ("userId","segmentId");
--------------------------------------------------------------------------------
/source.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ -f .env ]; then
4 | # Read each line from .env file
5 | while IFS= read -r line || [ -n "$line" ]; do
6 | # Skip empty lines and comments
7 | if [[ $line =~ ^[[:space:]]*$ ]] || [[ $line =~ ^# ]]; then
8 | continue
9 | fi
10 |
11 | # Export the environment variable
12 | export "$line"
13 | done < .env
14 | echo "Environment variables loaded successfully"
15 | else
16 | echo "Error: .env file not found"
17 | exit 1
18 | fi
19 |
20 |
--------------------------------------------------------------------------------
/src/routes/learn/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, Link } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/learn/not-found")({
4 | component: RouteComponent,
5 | });
6 |
7 | function RouteComponent() {
8 | return (
9 |
10 |
Not found
11 |
The segment you are looking for does not exist.
12 |
Start learning
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/use-window-size.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useWindowSize() {
4 | const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
5 |
6 | useEffect(() => {
7 | const handleResize = () => {
8 | setWindowSize({ width: window.innerWidth, height: window.innerHeight });
9 | };
10 | handleResize();
11 | window.addEventListener("resize", handleResize);
12 | return () => window.removeEventListener("resize", handleResize);
13 | }, []);
14 |
15 | return windowSize;
16 | }
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "esModuleInterop": true,
5 | "jsx": "react-jsx",
6 | "moduleResolution": "Bundler",
7 | "module": "ESNext",
8 | "target": "ES2022",
9 | "skipLibCheck": true,
10 | "strictNullChecks": true,
11 | "isolatedModules": true,
12 | "resolveJsonModule": true,
13 | "allowJs": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "baseUrl": ".",
16 | "verbatimModuleSyntax": false,
17 | "paths": {
18 | "~/*": ["./src/*"]
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/drizzle/migrate/migrate.ts:
--------------------------------------------------------------------------------
1 | // This file is to get the migration to run in the Dockerfile right
2 | // before the service runs.
3 |
4 | import "dotenv/config";
5 | import { drizzle } from "drizzle-orm/postgres-js";
6 | import postgres from "postgres";
7 | import { migrate } from "drizzle-orm/postgres-js/migrator";
8 | import { env } from "~/utils/env";
9 |
10 | const pg = postgres(env.DATABASE_URL!);
11 | const database = drizzle(pg);
12 |
13 | async function main() {
14 | await migrate(database, { migrationsFolder: ".." });
15 | await pg.end();
16 | }
17 |
18 | main();
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 |
3 | import tsConfigPaths from "vite-tsconfig-paths";
4 | import tailwindcss from "@tailwindcss/vite";
5 | import { tanstackStart } from "@tanstack/react-start/plugin/vite";
6 | import viteReact from "@vitejs/plugin-react";
7 |
8 | export default defineConfig({
9 | server: { port: 3000 },
10 | ssr: { noExternal: ["react-dropzone"] },
11 | plugins: [
12 | tailwindcss(),
13 | tsConfigPaths(),
14 | tanstackStart({ target: "node-server", customViteReactPlugin: true }),
15 | viteReact(),
16 | ],
17 | });
18 |
--------------------------------------------------------------------------------
/src/data-access/testimonials.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import {
3 | type Testimonial,
4 | type TestimonialCreate,
5 | testimonials,
6 | } from "~/db/schema";
7 |
8 | export async function createTestimonial(data: TestimonialCreate) {
9 | return await database.insert(testimonials).values(data).returning();
10 | }
11 |
12 | export async function getTestimonials() {
13 | return await database.query.testimonials.findMany({
14 | with: {
15 | profile: true,
16 | },
17 | orderBy: (testimonials, { desc }) => [desc(testimonials.createdAt)],
18 | });
19 | }
20 |
--------------------------------------------------------------------------------
/drizzle/0007_loose_maximus.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "app_module" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "title" text NOT NULL,
4 | "order" integer NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL,
6 | "updated_at" timestamp DEFAULT now() NOT NULL
7 | );
8 | --> statement-breakpoint
9 | ALTER TABLE "app_segment" ALTER COLUMN "moduleId" TYPE integer USING "moduleId"::integer;
10 | --> statement-breakpoint
11 | ALTER TABLE "app_segment" ADD CONSTRAINT "app_segment_moduleId_app_module_id_fk" FOREIGN KEY ("moduleId") REFERENCES "public"."app_module"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/drizzle/0010_red_maestro.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "app_testimonial" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "userId" serial NOT NULL,
4 | "content" text NOT NULL,
5 | "emojis" text NOT NULL,
6 | "displayName" text NOT NULL,
7 | "permissionGranted" boolean DEFAULT false NOT NULL,
8 | "created_at" timestamp DEFAULT now() NOT NULL,
9 | "updated_at" timestamp DEFAULT now() NOT NULL
10 | );
11 | --> statement-breakpoint
12 | ALTER TABLE "app_testimonial" ADD CONSTRAINT "app_testimonial_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/admin-controls.tsx:
--------------------------------------------------------------------------------
1 | import { type Segment } from "~/db/schema";
2 | import { EditVideoButton } from "./edit-video-button";
3 | import { DeleteSegmentButton } from "./delete-segment-button";
4 |
5 | interface AdminControlsProps {
6 | currentSegment: Segment;
7 | }
8 |
9 | export function AdminControls({ currentSegment }: AdminControlsProps) {
10 | return (
11 |
12 |
13 |
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/use-cases/progress.ts:
--------------------------------------------------------------------------------
1 | import { Segment } from "~/db/schema";
2 | import { UserId } from "./types";
3 | import {
4 | getProgress,
5 | markAsWatched,
6 | getAllProgressForUser,
7 | } from "~/data-access/progress";
8 |
9 | export async function markAsWatchedUseCase(
10 | userId: UserId,
11 | segmentId: Segment["id"]
12 | ) {
13 | const progress = await getProgress(userId, segmentId);
14 | if (!progress) {
15 | return markAsWatched(userId, segmentId);
16 | }
17 | }
18 |
19 | export async function getAllProgressForUserUseCase(userId: UserId) {
20 | return getAllProgressForUser(userId);
21 | }
22 |
--------------------------------------------------------------------------------
/src/hooks/use-prevent-tab-close.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | export function usePreventTabClose(shouldPrevent: boolean) {
4 | useEffect(() => {
5 | function handleBeforeUnload(event: BeforeUnloadEvent) {
6 | if (shouldPrevent) {
7 | event.preventDefault();
8 | // event.returnValue = ""; // required for most browsers to show the dialog
9 | }
10 | }
11 |
12 | window.addEventListener("beforeunload", handleBeforeUnload);
13 | return () => {
14 | window.removeEventListener("beforeunload", handleBeforeUnload);
15 | };
16 | }, [shouldPrevent]);
17 | }
18 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/edit-video-button.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 | import { Button } from "~/components/ui/button";
3 | import { Edit } from "lucide-react";
4 | import { type Segment } from "~/db/schema";
5 |
6 | interface EditVideoButtonProps {
7 | currentSegment: Segment;
8 | }
9 |
10 | export function EditVideoButton({ currentSegment }: EditVideoButtonProps) {
11 | return (
12 |
13 |
14 |
15 | Edit
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/fn/storage.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { adminMiddleware } from "~/lib/auth";
3 | import { z } from "zod";
4 | import { getStorage } from "~/utils/storage";
5 |
6 | export const getPresignedUploadUrlFn = createServerFn({ method: "POST" })
7 | .middleware([adminMiddleware])
8 | .validator(
9 | z.object({
10 | videoKey: z.string(),
11 | })
12 | )
13 | .handler(async ({ data }) => {
14 | const { storage } = getStorage();
15 | const presignedUrl = await storage.getPresignedUploadUrl(data.videoKey);
16 |
17 | return { presignedUrl, videoKey: data.videoKey };
18 | });
19 |
--------------------------------------------------------------------------------
/src/utils/env.ts:
--------------------------------------------------------------------------------
1 | export const env = {
2 | DATABASE_URL: process.env.DATABASE_URL!,
3 | GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID!,
4 | GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET!,
5 | HOST_NAME: process.env.HOST_NAME!,
6 | NODE_ENV: process.env.NODE_ENV!,
7 | STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY!,
8 | STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID!,
9 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET!,
10 | RECAPTCHA_SECRET_KEY: process.env.RECAPTCHA_SECRET_KEY!,
11 | MAILING_LIST_ENDPOINT: process.env.MAILING_LIST_ENDPOINT!,
12 | MAILING_LIST_PASSWORD: process.env.MAILING_LIST_PASSWORD!,
13 | };
14 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/container.tsx:
--------------------------------------------------------------------------------
1 | export function Container({ children }: { children: React.ReactNode }) {
2 | return (
3 |
4 | {/* Background decoration */}
5 | {/*
*/}
6 |
7 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/storage/storage.interface.ts:
--------------------------------------------------------------------------------
1 | export interface IStorage {
2 | upload(key: string, data: Buffer): Promise;
3 | delete(key: string): Promise;
4 | getStream(
5 | key: string,
6 | rangeHeader: string | null
7 | ): Promise;
8 | getPresignedUrl(key: string): Promise;
9 | getPresignedUploadUrl(key: string): Promise;
10 | }
11 |
12 | export type StreamFileRange = Partial<{
13 | start: number;
14 | end: number;
15 | }>;
16 |
17 | export type StreamFileResponse = {
18 | stream: ReadableStream;
19 | contentLength: number;
20 | contentType: string;
21 | contentRange?: string;
22 | };
23 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/src/fn/segments.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { getSegments } from "~/data-access/segments";
3 | import { adminMiddleware } from "~/lib/auth";
4 | import { z } from "zod";
5 | import { reorderSegmentsUseCase } from "~/use-cases/segments";
6 |
7 | export const getFirstSegmentFn = createServerFn().handler(async () => {
8 | const segments = await getSegments();
9 | return segments[0] ?? null;
10 | });
11 |
12 | export const reorderSegmentsFn = createServerFn()
13 | .middleware([adminMiddleware])
14 | .validator(z.array(z.object({ id: z.number(), order: z.number() })))
15 | .handler(async ({ data }) => {
16 | return reorderSegmentsUseCase(data);
17 | });
18 |
--------------------------------------------------------------------------------
/src/routes/api/logout.ts:
--------------------------------------------------------------------------------
1 | import { createServerFileRoute } from "@tanstack/react-start/server";
2 | import { invalidateSession, validateRequest } from "~/utils/auth";
3 | import { deleteSessionTokenCookie } from "~/utils/session";
4 |
5 | export const ServerRoute = createServerFileRoute("/api/logout").methods({
6 | GET: async ({ request, params }) => {
7 | const { session } = await validateRequest();
8 | if (!session) {
9 | return new Response(null, { status: 302, headers: { Location: "/" } });
10 | }
11 | await invalidateSession(session?.id);
12 | await deleteSessionTokenCookie();
13 | return new Response(null, { status: 302, headers: { Location: "/" } });
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/drizzle/0011_clear_christian_walker.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "app_comment" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "userId" serial NOT NULL,
4 | "segmentId" serial NOT NULL,
5 | "content" text NOT NULL,
6 | "created_at" timestamp DEFAULT now() NOT NULL,
7 | "updated_at" timestamp DEFAULT now() NOT NULL
8 | );
9 | --> statement-breakpoint
10 | ALTER TABLE "app_comment" ADD CONSTRAINT "app_comment_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
11 | ALTER TABLE "app_comment" ADD CONSTRAINT "app_comment_segmentId_app_segment_id_fk" FOREIGN KEY ("segmentId") REFERENCES "public"."app_segment"("id") ON DELETE cascade ON UPDATE no action;
--------------------------------------------------------------------------------
/src/use-cases/types.ts:
--------------------------------------------------------------------------------
1 | export type Plan = "free" | "basic" | "premium";
2 | export type Role = "owner" | "admin" | "member";
3 |
4 | export type UserId = number;
5 |
6 | export type UserProfile = {
7 | id: UserId;
8 | name: string | null;
9 | image: string | null;
10 | };
11 |
12 | export type UserSession = {
13 | id: UserId;
14 | };
15 |
16 | export type MemberInfo = {
17 | name: string | null;
18 | userId: UserId;
19 | image: string | null;
20 | role: Role;
21 | };
22 |
23 | export interface GoogleUser {
24 | sub: string;
25 | name: string;
26 | given_name: string;
27 | family_name: string;
28 | picture: string;
29 | email: string;
30 | email_verified: boolean;
31 | locale: string;
32 | }
33 |
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | DATABASE_URL="postgresql://postgres:example@localhost:5432/postgres"
2 |
3 | GOOGLE_CLIENT_ID=
4 | GOOGLE_CLIENT_SECRET=
5 |
6 | HOST_NAME=http://localhost:3000
7 |
8 | NODE_ENV=development
9 |
10 | STRIPE_SECRET_KEY=your_stripe_secret_key
11 | STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
12 | STRIPE_PRICE_ID=your_stripe_price_id
13 |
14 | R2_ENDPOINT=
15 | R2_ACCESS_KEY_ID=
16 | R2_SECRET_ACCESS_KEY=
17 | R2_BUCKET=
18 |
19 | RECAPTCHA_SECRET_KEY=CHANGE_ME
20 |
21 | MAILING_LIST_ENDPOINT=https://mailing-list.webdevcody.com/api/subscribe
22 | MAILING_LIST_PASSWORD=CHANGE_ME
23 |
24 | VITE_RECAPTCHA_KEY=CHANGE_ME
25 | VITE_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
26 | VITE_FILE_URL=http://localhost:3000/api/videos
27 |
--------------------------------------------------------------------------------
/src/routes/unauthorized.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, useNavigate } from "@tanstack/react-router";
2 | import { Button } from "~/components/ui/button";
3 |
4 | export const Route = createFileRoute("/unauthorized")({
5 | component: RouteComponent,
6 | });
7 |
8 | function RouteComponent() {
9 | const navigate = useNavigate();
10 |
11 | return (
12 |
13 |
Unauthorized
14 |
15 | You are not authorized to view this content.
16 |
17 |
navigate({ to: "/" })}>Go to Home
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/feedback-button.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 | import { MessageSquare } from "lucide-react";
3 | import { Button } from "~/components/ui/button";
4 |
5 | export function FloatingFeedbackButton() {
6 | return (
7 |
8 |
12 |
13 | Leave a Testimonial
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/db/clear.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 |
3 | import { database, pool } from "./index";
4 | import { sql } from "drizzle-orm";
5 |
6 | async function main() {
7 | const tablesSchema = database._.schema;
8 | if (!tablesSchema) throw new Error("Schema not loaded");
9 |
10 | await database.execute(sql.raw(`DROP SCHEMA IF EXISTS "drizzle" CASCADE;`));
11 |
12 | await database.execute(sql.raw(`DROP SCHEMA public CASCADE;`));
13 | await database.execute(sql.raw(`CREATE SCHEMA public;`));
14 | await database.execute(sql.raw(`GRANT ALL ON SCHEMA public TO postgres;`));
15 | await database.execute(sql.raw(`GRANT ALL ON SCHEMA public TO public;`));
16 | await database.execute(
17 | sql.raw(`COMMENT ON SCHEMA public IS 'standard public schema';`)
18 | );
19 | await pool.end();
20 | }
21 |
22 | main();
23 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/content-panel.tsx:
--------------------------------------------------------------------------------
1 | import { FileText } from "lucide-react";
2 | import { type Segment } from "~/db/schema";
3 | import { MarkdownContent } from "~/routes/learn/-components/markdown-content";
4 |
5 | interface ContentPanelProps {
6 | currentSegment: Segment;
7 | }
8 |
9 | export function ContentPanel({ currentSegment }: ContentPanelProps) {
10 | if (currentSegment.content) {
11 | return (
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | return (
19 |
20 |
21 |
No lesson content available for this segment.
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "~/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/src/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 |
--------------------------------------------------------------------------------
/src/use-cases/attachments.ts:
--------------------------------------------------------------------------------
1 | import { getAttachment } from "~/data-access/attachments";
2 | import { createAttachment, deleteAttachment } from "~/data-access/segments";
3 | import { Segment } from "~/db/schema";
4 |
5 | export async function deleteAttachmentUseCase(attachmentId: number) {
6 | const attachment = await getAttachment(attachmentId);
7 |
8 | if (!attachment) {
9 | throw new Error("Attachment not found");
10 | }
11 |
12 | // await deleteFile(attachment.fileKey);
13 | return deleteAttachment(attachmentId);
14 | }
15 |
16 | export async function createAttachmentUseCase(attachment: {
17 | segmentId: Segment["id"];
18 | fileName: string;
19 | fileKey: string;
20 | }) {
21 | return createAttachment({
22 | segmentId: attachment.segmentId,
23 | fileName: attachment.fileName,
24 | fileKey: attachment.fileKey,
25 | });
26 | }
27 |
--------------------------------------------------------------------------------
/src/routes/cancel.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 |
3 | export const Route = createFileRoute("/cancel")({ component: RouteComponent });
4 |
5 | function RouteComponent() {
6 | return (
7 |
8 |
9 |
Purchase Cancelled
10 |
11 | Your purchase was cancelled. No charges were made.
12 |
13 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/routes/unauthenticated.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { Button } from "~/components/ui/button";
3 | import { LogIn } from "lucide-react";
4 |
5 | export const Route = createFileRoute("/unauthenticated")({
6 | component: RouteComponent,
7 | });
8 |
9 | function RouteComponent() {
10 | return (
11 |
12 |
13 |
Authentication Required
14 |
15 | You must be logged in to view this content. Please sign in to continue.
16 |
17 |
18 | Sign in with Google
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ProgressPrimitive from "@radix-ui/react-progress";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | function Progress({
7 | className,
8 | value,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
25 |
26 | );
27 | }
28 |
29 | export { Progress };
30 |
--------------------------------------------------------------------------------
/src/data-access/accounts.ts:
--------------------------------------------------------------------------------
1 | import { eq } from "drizzle-orm";
2 | import { database } from "~/db";
3 | import { accounts } from "~/db/schema";
4 | import { UserId } from "~/use-cases/types";
5 |
6 | export async function createAccountViaGoogle(userId: UserId, googleId: string) {
7 | await database
8 | .insert(accounts)
9 | .values({
10 | userId: userId,
11 | googleId,
12 | })
13 | .onConflictDoNothing()
14 | .returning();
15 | }
16 |
17 | export async function getAccountByUserId(userId: UserId) {
18 | const account = await database.query.accounts.findFirst({
19 | where: eq(accounts.userId, userId),
20 | });
21 |
22 | return account;
23 | }
24 |
25 | export async function getAccountByGoogleId(googleId: string) {
26 | return await database.query.accounts.findFirst({
27 | where: eq(accounts.googleId, googleId),
28 | });
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@tanstack/react-router'
2 |
3 | export function NotFound({ children }: { children?: any }) {
4 | return (
5 |
6 |
7 | {children ||
The page you are looking for does not exist.
}
8 |
9 |
10 | window.history.back()}
12 | className="bg-emerald-500 text-white px-2 py-1 rounded uppercase font-black text-sm"
13 | >
14 | Go back
15 |
16 |
20 | Start Over
21 |
22 |
23 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/src/data-access/progress.ts:
--------------------------------------------------------------------------------
1 | import { and, eq } from "drizzle-orm";
2 | import { progress, Progress } from "~/db/schema";
3 | import { UserId } from "~/use-cases/types";
4 | import { database } from "~/db";
5 |
6 | export async function getProgress(
7 | userId: UserId,
8 | segmentId: Progress["segmentId"]
9 | ) {
10 | const progressEntry = await database.query.progress.findFirst({
11 | where: and(eq(progress.segmentId, segmentId), eq(progress.userId, userId)),
12 | });
13 | return progressEntry;
14 | }
15 |
16 | export async function getAllProgressForUser(userId: UserId) {
17 | return database.query.progress.findMany({
18 | where: eq(progress.userId, userId),
19 | });
20 | }
21 |
22 | export async function markAsWatched(
23 | userId: UserId,
24 | segmentId: Progress["segmentId"]
25 | ) {
26 | await database.insert(progress).values({ segmentId, userId });
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/segment-context.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState } from "react";
2 |
3 | interface SegmentContextType {
4 | currentSegmentId: number | null;
5 | setCurrentSegmentId: (id: number) => void;
6 | }
7 |
8 | const SegmentContext = createContext(undefined);
9 |
10 | export function SegmentProvider({ children }: { children: React.ReactNode }) {
11 | const [currentSegmentId, setCurrentSegmentId] = useState(null);
12 |
13 | return (
14 |
15 | {children}
16 |
17 | );
18 | }
19 |
20 | export function useSegment() {
21 | const context = useContext(SegmentContext);
22 | if (context === undefined) {
23 | throw new Error("useSegment must be used within a SegmentProvider");
24 | }
25 | return context;
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import { useToast } from "~/hooks/use-toast"
2 | import {
3 | Toast,
4 | ToastClose,
5 | ToastDescription,
6 | ToastProvider,
7 | ToastTitle,
8 | ToastViewport,
9 | } from "~/components/ui/toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title} }
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/src/utils/users.tsx:
--------------------------------------------------------------------------------
1 | import { queryOptions } from '@tanstack/react-query'
2 | import axios from 'redaxios'
3 |
4 | export type User = {
5 | id: number
6 | name: string
7 | email: string
8 | }
9 |
10 | export const DEPLOY_URL = 'http://localhost:3000'
11 |
12 | export const usersQueryOptions = () =>
13 | queryOptions({
14 | queryKey: ['users'],
15 | queryFn: () =>
16 | axios
17 | .get>(DEPLOY_URL + '/api/users')
18 | .then((r) => r.data)
19 | .catch(() => {
20 | throw new Error('Failed to fetch users')
21 | }),
22 | })
23 |
24 | export const userQueryOptions = (id: string) =>
25 | queryOptions({
26 | queryKey: ['users', id],
27 | queryFn: () =>
28 | axios
29 | .get(DEPLOY_URL + '/api/users/' + id)
30 | .then((r) => r.data)
31 | .catch(() => {
32 | throw new Error('Failed to fetch user')
33 | }),
34 | })
35 |
--------------------------------------------------------------------------------
/src/hooks/use-continue-slug.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { useFirstSegment } from "./use-first-segment";
3 | import { getLastWatchedSegment } from "~/utils/local-storage";
4 |
5 | export function useContinueSlug() {
6 | const [courseLink, setCourseLink] = useState("");
7 | const firstSegment = useFirstSegment();
8 |
9 | useEffect(() => {
10 | const initializeCourseLink = async () => {
11 | const lastWatched = getLastWatchedSegment();
12 | if (lastWatched) {
13 | setCourseLink(lastWatched);
14 | } else if (firstSegment.data?.slug) {
15 | setCourseLink(firstSegment.data.slug);
16 | } else {
17 | // If no first segment is available, set to empty string
18 | // This will be handled by the header component
19 | setCourseLink("");
20 | }
21 | };
22 |
23 | initializeCourseLink();
24 | }, [firstSegment.data]);
25 |
26 | return courseLink;
27 | }
28 |
--------------------------------------------------------------------------------
/src/data-access/profiles.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import { Profile, profiles } from "~/db/schema";
3 | import { UserId } from "~/use-cases/types";
4 | import { eq } from "drizzle-orm";
5 |
6 | export async function createProfile(
7 | userId: UserId,
8 | displayName: string,
9 | image?: string
10 | ) {
11 | const [profile] = await database
12 | .insert(profiles)
13 | .values({
14 | userId,
15 | image,
16 | displayName,
17 | })
18 | .onConflictDoNothing()
19 | .returning();
20 | return profile;
21 | }
22 |
23 | export async function updateProfile(
24 | userId: UserId,
25 | updateProfile: Partial
26 | ) {
27 | await database
28 | .update(profiles)
29 | .set(updateProfile)
30 | .where(eq(profiles.userId, userId));
31 | }
32 |
33 | export async function getProfile(userId: UserId) {
34 | const profile = await database.query.profiles.findFirst({
35 | where: eq(profiles.userId, userId),
36 | });
37 |
38 | return profile;
39 | }
40 |
--------------------------------------------------------------------------------
/src/utils/seo.ts:
--------------------------------------------------------------------------------
1 | export const seo = ({
2 | title,
3 | description,
4 | keywords,
5 | image,
6 | }: {
7 | title: string
8 | description?: string
9 | image?: string
10 | keywords?: string
11 | }) => {
12 | const tags = [
13 | { title },
14 | { name: 'description', content: description },
15 | { name: 'keywords', content: keywords },
16 | { name: 'twitter:title', content: title },
17 | { name: 'twitter:description', content: description },
18 | { name: 'twitter:creator', content: '@tannerlinsley' },
19 | { name: 'twitter:site', content: '@tannerlinsley' },
20 | { name: 'og:type', content: 'website' },
21 | { name: 'og:title', content: title },
22 | { name: 'og:description', content: description },
23 | ...(image
24 | ? [
25 | { name: 'twitter:image', content: image },
26 | { name: 'twitter:card', content: 'summary_large_image' },
27 | { name: 'og:image', content: image },
28 | ]
29 | : []),
30 | ]
31 |
32 | return tags
33 | }
34 |
--------------------------------------------------------------------------------
/src/fn/auth.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from "@tanstack/react-router";
2 | import { createServerFn } from "@tanstack/react-start";
3 | import { unauthenticatedMiddleware } from "~/lib/auth";
4 | import { isAdminUseCase } from "~/use-cases/users";
5 | import { validateRequest } from "~/utils/auth";
6 |
7 | export const isAuthenticatedFn = createServerFn().handler(async () => {
8 | const { user } = await validateRequest();
9 | return !!user;
10 | });
11 |
12 | export const assertAuthenticatedFn = createServerFn().handler(async () => {
13 | const { user } = await validateRequest();
14 |
15 | if (!user) {
16 | throw redirect({ to: "/unauthenticated" });
17 | }
18 |
19 | return user;
20 | });
21 |
22 | export const isUserPremiumFn = createServerFn().handler(async () => {
23 | const { user } = await validateRequest();
24 | return !!user?.isPremium;
25 | });
26 |
27 | export const isAdminFn = createServerFn()
28 | .middleware([unauthenticatedMiddleware])
29 | .handler(async () => {
30 | return isAdminUseCase();
31 | });
32 |
--------------------------------------------------------------------------------
/src/use-cases/errors.ts:
--------------------------------------------------------------------------------
1 | export class PublicError extends Error {
2 | constructor(message: string) {
3 | super(message);
4 | }
5 | }
6 |
7 | export class AuthenticationError extends PublicError {
8 | constructor() {
9 | super("You must be logged in to view this content");
10 | this.name = "AuthenticationError";
11 | }
12 | }
13 |
14 | export class EmailInUseError extends PublicError {
15 | constructor() {
16 | super("Email is already in use");
17 | this.name = "EmailInUseError";
18 | }
19 | }
20 |
21 | export class NotFoundError extends PublicError {
22 | constructor() {
23 | super("Resource not found");
24 | this.name = "NotFoundError";
25 | }
26 | }
27 |
28 | export class TokenExpiredError extends PublicError {
29 | constructor() {
30 | super("Token has expired");
31 | this.name = "TokenExpiredError";
32 | }
33 | }
34 |
35 | export class LoginError extends PublicError {
36 | constructor() {
37 | super("Invalid email or password");
38 | this.name = "LoginError";
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/assignment-viewer.tsx:
--------------------------------------------------------------------------------
1 | import { FileText } from "lucide-react";
2 |
3 | interface AssignmentViewerProps {
4 | assignments: string[];
5 | }
6 |
7 | export function AssignmentViewer({ assignments }: AssignmentViewerProps) {
8 | return (
9 |
10 |
Assignments
11 | {assignments.length === 0 ? (
12 |
No assignments available yet.
13 | ) : (
14 |
29 | )}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 | export function getTimeAgo(date: Date) {
8 | const now = new Date();
9 | const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000);
10 |
11 | const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
12 |
13 | const ranges = [
14 | { unit: "second", seconds: 60 },
15 | { unit: "minute", seconds: 60 * 60 },
16 | { unit: "hour", seconds: 60 * 60 * 24 },
17 | { unit: "day", seconds: 60 * 60 * 24 * 7 },
18 | { unit: "week", seconds: 60 * 60 * 24 * 30 },
19 | { unit: "month", seconds: 60 * 60 * 24 * 365 },
20 | { unit: "year", seconds: Infinity },
21 | ] as const;
22 |
23 | for (const range of ranges) {
24 | if (Math.abs(diffInSeconds) < range.seconds) {
25 | const value = Math.round(
26 | diffInSeconds / (range.seconds / (range.unit === "second" ? 1 : 60))
27 | );
28 | return rtf.format(value, range.unit);
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Web Dev Cody
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/use-cases/util.ts:
--------------------------------------------------------------------------------
1 | import { ZodTypeAny, z } from "zod";
2 |
3 | type ValidatedCallbackOptions<
4 | CallbackInput,
5 | OutputValidation extends ZodTypeAny,
6 | InputValidation extends ZodTypeAny
7 | > = {
8 | outputs?: OutputValidation;
9 | inputs?: InputValidation;
10 | handler: InputValidation extends ZodTypeAny
11 | ? (input: z.infer) => any
12 | : (input: CallbackInput) => any;
13 | };
14 |
15 | export function createUseCase<
16 | CallbackInput,
17 | OutputValidation extends ZodTypeAny,
18 | InputValidation extends ZodTypeAny
19 | >(
20 | options: ValidatedCallbackOptions<
21 | CallbackInput,
22 | OutputValidation,
23 | InputValidation
24 | >
25 | ) {
26 | return async function (
27 | input: CallbackInput
28 | ): Promise> {
29 | const passthrough = { parse: (i: any) => i };
30 | const { inputs, handler, outputs } = options;
31 | const validatedInput = (inputs ?? passthrough).parse(input);
32 | const outputResult = await handler(validatedInput);
33 | const parsedOutput = (outputs ?? passthrough).parse(outputResult);
34 | return parsedOutput;
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
3 | import { Check } from "lucide-react";
4 | import { cn } from "~/lib/utils";
5 |
6 | const Checkbox = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
21 |
22 |
23 |
24 | ));
25 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
26 |
27 | export { Checkbox };
28 |
--------------------------------------------------------------------------------
/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query";
2 | import { createRouter as createTanStackRouter } from "@tanstack/react-router";
3 | import { routerWithQueryClient } from "@tanstack/react-router-with-query";
4 | import { routeTree } from "./routeTree.gen";
5 | import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary";
6 | import { NotFound } from "./components/NotFound";
7 |
8 | // NOTE: Most of the integration code found here is experimental and will
9 | // definitely end up in a more streamlined API in the future. This is just
10 | // to show what's possible with the current APIs.
11 |
12 | export function createRouter() {
13 | const queryClient = new QueryClient();
14 |
15 | return routerWithQueryClient(
16 | createTanStackRouter({
17 | routeTree,
18 | scrollRestoration: true,
19 | context: { queryClient },
20 | defaultPreload: "intent",
21 | defaultErrorComponent: DefaultCatchBoundary,
22 | defaultNotFoundComponent: () => ,
23 | }),
24 | queryClient
25 | );
26 | }
27 |
28 | declare module "@tanstack/react-router" {
29 | interface Register {
30 | router: ReturnType;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/video-duration.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Get the duration of a video file in seconds
3 | */
4 | export function getVideoDuration(file: File): Promise {
5 | return new Promise((resolve, reject) => {
6 | const video = document.createElement("video");
7 | const url = URL.createObjectURL(file);
8 |
9 | video.preload = "metadata";
10 | video.src = url;
11 |
12 | video.onloadedmetadata = () => {
13 | URL.revokeObjectURL(url);
14 | resolve(video.duration);
15 | };
16 |
17 | video.onerror = () => {
18 | URL.revokeObjectURL(url);
19 | reject(new Error("Failed to load video metadata"));
20 | };
21 | });
22 | }
23 |
24 | /**
25 | * Format duration in seconds to a human-readable string (e.g., "2:34", "1:23:45")
26 | */
27 | export function formatDuration(seconds: number): string {
28 | const hours = Math.floor(seconds / 3600);
29 | const minutes = Math.floor((seconds % 3600) / 60);
30 | const remainingSeconds = Math.floor(seconds % 60);
31 |
32 | if (hours > 0) {
33 | return `${hours}:${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
34 | } else {
35 | return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "~/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md 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 shadow 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 shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/navigation.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import { ChevronLeft, ChevronRight } from "lucide-react";
3 | import type { Segment } from "~/db/schema";
4 |
5 | interface NavigationProps {
6 | prevSegment: Segment | null;
7 | nextSegment: Segment | null;
8 | }
9 |
10 | export function Navigation({ prevSegment, nextSegment }: NavigationProps) {
11 | return (
12 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/data-access/comments.ts:
--------------------------------------------------------------------------------
1 | import { and, desc, eq, isNull } from "drizzle-orm";
2 | import { database } from "~/db";
3 | import { CommentCreate, comments } from "~/db/schema";
4 |
5 | export type CommentsWithUser = Awaited>;
6 |
7 | export async function getComments(segmentId: number) {
8 | return database.query.comments.findMany({
9 | where: and(eq(comments.segmentId, segmentId), isNull(comments.parentId)),
10 | with: {
11 | profile: true,
12 | children: {
13 | with: {
14 | profile: true,
15 | repliedToProfile: true,
16 | },
17 | },
18 | },
19 | orderBy: [desc(comments.createdAt)],
20 | });
21 | }
22 |
23 | export async function createComment(comment: CommentCreate) {
24 | return database.insert(comments).values(comment);
25 | }
26 |
27 | export async function deleteComment(commentId: number, userId: number) {
28 | return database
29 | .delete(comments)
30 | .where(and(eq(comments.id, commentId), eq(comments.userId, userId)));
31 | }
32 |
33 | export async function updateComment(
34 | commentId: number,
35 | content: string,
36 | userId: number
37 | ) {
38 | return database
39 | .update(comments)
40 | .set({ content })
41 | .where(and(eq(comments.id, commentId), eq(comments.userId, userId)));
42 | }
43 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
17 |
26 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/routes/learn/no-segments.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, Link } from "@tanstack/react-router";
2 | import { isAdminFn } from "~/fn/auth";
3 |
4 | export const Route = createFileRoute("/learn/no-segments")({
5 | component: RouteComponent,
6 | loader: async () => {
7 | const isAdmin = await isAdminFn();
8 | return { isAdmin };
9 | },
10 | });
11 |
12 | function RouteComponent() {
13 | const { isAdmin } = Route.useLoaderData();
14 | return (
15 |
16 |
No Learning Content Available
17 |
18 | {isAdmin
19 | ? "You have not added any learning content yet."
20 | : "The course owner has not added any learning content yet. Please check back later."}
21 |
22 | {isAdmin ? (
23 |
27 | Create a Module
28 |
29 | ) : (
30 |
34 | Back to Home
35 |
36 | )}
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/docker-compose.prod.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | wdc-tanstack-starter-kit-db:
4 | image: postgres:17
5 | restart: always
6 | container_name: wdc-tanstack-starter-kit-db
7 | ports:
8 | - 127.0.0.1:5432:5432
9 | env_file:
10 | - .env.postgres
11 | volumes:
12 | - postgres:/data/postgres
13 |
14 | wdc-tanstack-starter-kit-app:
15 | build:
16 | context: .
17 | dockerfile: Dockerfile
18 | image: wdc-tanstack-starter-kit-app
19 | env_file:
20 | - .env
21 | restart: always
22 | container_name: wdc-tanstack-starter-kit-app
23 | volumes:
24 | - app_files:/app/files
25 |
26 | caddy:
27 | image: caddy:2
28 | restart: always
29 | container_name: wdc-tanstack-starter-kit-caddy
30 | ports:
31 | - "80:80"
32 | - "443:443"
33 | volumes:
34 | - ./Caddyfile:/etc/caddy/Caddyfile
35 | - caddy_data:/data
36 | - caddy_config:/config
37 | depends_on:
38 | - wdc-tanstack-starter-kit-app
39 |
40 | # tunnel:
41 | # container_name: cloudflared-tunnel
42 | # image: cloudflare/cloudflared
43 | # restart: unless-stopped
44 | # command: tunnel run
45 | # environment:
46 | # - TUNNEL_TOKEN=mytokengoeshere
47 |
48 | volumes:
49 | postgres:
50 | caddy_data:
51 | caddy_config:
52 | app_files:
53 |
--------------------------------------------------------------------------------
/src/components/ModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 |
3 | import { Button } from "~/components/ui/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuTrigger,
9 | } from "~/components/ui/dropdown-menu";
10 | import { useTheme } from "~/components/ThemeProvider";
11 |
12 | export function ModeToggle() {
13 | const { setTheme } = useTheme();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 | Toggle theme
22 |
23 |
24 |
25 | setTheme("light")}>
26 | Light
27 |
28 | setTheme("dark")}>
29 | Dark
30 |
31 | setTheme("system")}>
32 | System
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { HeroSection } from "./-components/hero";
3 | import { CodePreviewSection } from "./-components/code-preview";
4 | import { ModulesSection } from "./-components/modules";
5 | import { PricingSection } from "./-components/pricing";
6 | import { FAQSection } from "./-components/faq";
7 | import { createServerFn } from "@tanstack/react-start";
8 | import { getSegmentsUseCase } from "~/use-cases/segments";
9 | import { NewsletterSection } from "./-components/newsletter";
10 | import { TestimonialsSection } from "./-components/testimonials";
11 |
12 | const loaderFn = createServerFn().handler(async () => {
13 | const segments = await getSegmentsUseCase();
14 | return { segments };
15 | });
16 |
17 | export const Route = createFileRoute("/")({
18 | component: Home,
19 | loader: async () => {
20 | const [segments] = await Promise.all([loaderFn()]);
21 | return { ...segments };
22 | },
23 | });
24 |
25 | function Home() {
26 | const { segments } = Route.useLoaderData();
27 |
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/hooks/mutations/use-delete-comment.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { useLoaderData } from "@tanstack/react-router";
3 | import { deleteCommentFn } from "~/fn/comments";
4 | import { getCommentsQuery } from "~/lib/queries/comments";
5 | import { useAuth } from "../use-auth";
6 |
7 | export function useDeleteComment() {
8 | const queryClient = useQueryClient();
9 | const { segment } = useLoaderData({ from: "/learn/$slug/_layout/" });
10 | const user = useAuth();
11 | return useMutation({
12 | mutationFn: (commentId: { commentId: number }) =>
13 | deleteCommentFn({ data: commentId }),
14 | onSuccess: () => {
15 | queryClient.invalidateQueries(getCommentsQuery(segment.id));
16 | },
17 | onMutate: (variables) => {
18 | if (!user || !navigator.onLine) throw new Error("Something went wrong");
19 | const previousComments = queryClient.getQueryData(
20 | getCommentsQuery(segment.id).queryKey
21 | );
22 | queryClient.setQueryData(getCommentsQuery(segment.id).queryKey, (old) =>
23 | old?.filter((comment) => comment.id !== variables.commentId)
24 | );
25 | return { previousComments };
26 | },
27 | onError: (_, __, context) => {
28 | queryClient.setQueryData(
29 | getCommentsQuery(segment.id).queryKey,
30 | context?.previousComments
31 | );
32 | },
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as PopoverPrimitive from "@radix-ui/react-popover";
3 |
4 | import { cn } from "~/lib/utils";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverAnchor = PopoverPrimitive.Anchor;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
32 |
--------------------------------------------------------------------------------
/src/data-access/users.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import { User, users } from "~/db/schema";
3 | import { eq } from "drizzle-orm";
4 | import { UserId } from "~/use-cases/types";
5 |
6 | export async function deleteUser(userId: UserId) {
7 | await database.delete(users).where(eq(users.id, userId));
8 | }
9 |
10 | export async function getUser(userId: UserId) {
11 | const user = await database.query.users.findFirst({
12 | where: eq(users.id, userId),
13 | });
14 |
15 | return user;
16 | }
17 |
18 | export async function createUser(email: string) {
19 | const [user] = await database.insert(users).values({ email }).returning();
20 | return user;
21 | }
22 |
23 | export async function getUserByEmail(email: string) {
24 | const user = await database.query.users.findFirst({
25 | where: eq(users.email, email),
26 | });
27 |
28 | return user;
29 | }
30 |
31 | export async function setEmailVerified(userId: UserId) {
32 | await database
33 | .update(users)
34 | .set({ emailVerified: new Date() })
35 | .where(eq(users.id, userId));
36 | }
37 |
38 | export async function updateUser(userId: UserId, updatedUser: Partial) {
39 | await database.update(users).set(updatedUser).where(eq(users.id, userId));
40 | }
41 |
42 | export async function updateUserToPremium(userId: UserId) {
43 | await database
44 | .update(users)
45 | .set({ isPremium: true })
46 | .where(eq(users.id, userId));
47 | }
48 |
--------------------------------------------------------------------------------
/src/utils/posts.tsx:
--------------------------------------------------------------------------------
1 | import { queryOptions } from "@tanstack/react-query";
2 | import { notFound } from "@tanstack/react-router";
3 | import { createServerFn } from "@tanstack/react-start";
4 | import axios from "redaxios";
5 |
6 | export type PostType = { id: string; title: string; body: string };
7 |
8 | export const fetchPosts = createServerFn({ method: "GET" }).handler(
9 | async () => {
10 | console.info("Fetching posts...");
11 | return axios
12 | .get>("https://jsonplaceholder.typicode.com/posts")
13 | .then((r) => r.data.slice(0, 10));
14 | }
15 | );
16 |
17 | export const postsQueryOptions = () =>
18 | queryOptions({ queryKey: ["posts"], queryFn: () => fetchPosts() });
19 |
20 | export const fetchPost = createServerFn({ method: "GET" })
21 | .validator((d: string) => d)
22 | .handler(async ({ data }) => {
23 | console.info(`Fetching post with id ${data}...`);
24 | const post = await axios
25 | .get(`https://jsonplaceholder.typicode.com/posts/${data}`)
26 | .then((r) => r.data)
27 | .catch((err) => {
28 | console.error(err);
29 | if (err.status === 404) {
30 | throw notFound();
31 | }
32 | throw err;
33 | });
34 |
35 | return post;
36 | });
37 |
38 | export const postQueryOptions = (postId: string) =>
39 | queryOptions({
40 | queryKey: ["post", postId],
41 | queryFn: () => fetchPost({ data: postId }),
42 | });
43 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/add-segment/add-segment-header.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "@tanstack/react-router";
2 | import { Button } from "~/components/ui/button";
3 | import { ChevronLeft, Plus } from "lucide-react";
4 |
5 | export function AddSegmentHeader() {
6 | const router = useRouter();
7 |
8 | return (
9 |
10 |
11 |
12 |
router.history.back()}
16 | className="gap-2"
17 | >
18 |
19 | Back to Course
20 |
21 |
22 |
23 |
24 |
25 |
28 |
29 |
New Segment
30 |
31 | Create a learning segment
32 |
33 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/routes/api/login/google/index.ts:
--------------------------------------------------------------------------------
1 | import { createServerFileRoute } from "@tanstack/react-start/server";
2 | import { generateCodeVerifier, generateState } from "arctic";
3 | import { googleAuth } from "~/utils/auth";
4 | import { setCookie } from "@tanstack/react-start/server";
5 |
6 | const MAX_COOKIE_AGE_SECONDS = 60 * 10;
7 |
8 | export const ServerRoute = createServerFileRoute("/api/login/google/").methods({
9 | GET: async ({ request }) => {
10 | const url = new URL(request.url);
11 | const redirectUri = url.searchParams.get("redirect_uri") || "/";
12 |
13 | const state = generateState();
14 | const codeVerifier = generateCodeVerifier();
15 | const authorizationInfo = googleAuth.createAuthorizationURL(
16 | state,
17 | codeVerifier,
18 | ["profile", "email"]
19 | );
20 |
21 | setCookie("google_oauth_state", state, {
22 | path: "/",
23 | httpOnly: true,
24 | secure: true,
25 | sameSite: "lax",
26 | maxAge: MAX_COOKIE_AGE_SECONDS,
27 | });
28 |
29 | setCookie("google_code_verifier", codeVerifier, {
30 | path: "/",
31 | httpOnly: true,
32 | secure: true,
33 | sameSite: "lax",
34 | maxAge: MAX_COOKIE_AGE_SECONDS,
35 | });
36 |
37 | setCookie("google_redirect_uri", redirectUri, {
38 | path: "/",
39 | httpOnly: true,
40 | secure: true,
41 | sameSite: "lax",
42 | maxAge: MAX_COOKIE_AGE_SECONDS,
43 | });
44 |
45 | return Response.redirect(authorizationInfo.href);
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/src/styles/animations.css:
--------------------------------------------------------------------------------
1 | @keyframes tilt {
2 | 0%, 50%, 100% {
3 | transform: rotate(0deg);
4 | }
5 | 25% {
6 | transform: rotate(0.5deg);
7 | }
8 | 75% {
9 | transform: rotate(-0.5deg);
10 | }
11 | }
12 |
13 | @keyframes float {
14 | 0%, 100% {
15 | transform: translateY(0);
16 | }
17 | 50% {
18 | transform: translateY(-10px);
19 | }
20 | }
21 |
22 | @keyframes pulse {
23 | 0%, 100% {
24 | opacity: 1;
25 | transform: scale(1);
26 | }
27 | 50% {
28 | opacity: 0.5;
29 | transform: scale(1.05);
30 | }
31 | }
32 |
33 | @keyframes shimmer {
34 | 0% {
35 | background-position: 200% 0;
36 | }
37 | 100% {
38 | background-position: -200% 0;
39 | }
40 | }
41 |
42 | @keyframes glow {
43 | 0%, 100% {
44 | box-shadow: 0 0 20px rgba(52, 211, 153, 0.3);
45 | }
46 | 50% {
47 | box-shadow: 0 0 30px rgba(52, 211, 153, 0.6);
48 | }
49 | }
50 |
51 | @keyframes border-flow {
52 | 0% {
53 | background-position: 0% 50%;
54 | }
55 | 50% {
56 | background-position: 100% 50%;
57 | }
58 | 100% {
59 | background-position: 0% 50%;
60 | }
61 | }
62 |
63 | .animate-tilt {
64 | animation: tilt 1s ease-in-out infinite;
65 | }
66 |
67 | .animate-float {
68 | animation: float 3s ease-in-out infinite;
69 | }
70 |
71 | .animate-pulse-slow {
72 | animation: pulse 2s ease-in-out infinite;
73 | }
74 |
75 | .animate-shimmer {
76 | animation: shimmer 6s linear infinite;
77 | }
78 |
79 | .animate-glow {
80 | animation: glow 2s ease-in-out infinite;
81 | }
--------------------------------------------------------------------------------
/src/hooks/mutations/use-edit-comment.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { updateCommentFn, UpdateCommentSchema } from "~/fn/comments";
3 | import { useLoaderData } from "@tanstack/react-router";
4 | import { useAuth } from "../use-auth";
5 | import { getCommentsQuery } from "~/lib/queries/comments";
6 |
7 | export function useEditComment() {
8 | const { segment } = useLoaderData({ from: "/learn/$slug/_layout/" });
9 | const user = useAuth();
10 | const queryClient = useQueryClient();
11 | return useMutation({
12 | mutationFn: (variables: UpdateCommentSchema) =>
13 | updateCommentFn({ data: variables }),
14 | onSuccess: () => {
15 | queryClient.invalidateQueries(getCommentsQuery(segment.id));
16 | },
17 | onMutate: (variables) => {
18 | if (!user || !navigator.onLine) throw new Error("Something went wrong");
19 | const previousComments = queryClient.getQueryData(
20 | getCommentsQuery(segment.id).queryKey
21 | );
22 | queryClient.setQueryData(getCommentsQuery(segment.id).queryKey, (old) =>
23 | old?.map((comment) =>
24 | comment.id === variables.commentId
25 | ? { ...comment, content: variables.content }
26 | : comment
27 | )
28 | );
29 | return { previousComments };
30 | },
31 | onError: (_, __, context) => {
32 | queryClient.setQueryData(
33 | getCommentsQuery(segment.id).queryKey,
34 | context?.previousComments
35 | );
36 | },
37 | });
38 | }
39 |
--------------------------------------------------------------------------------
/src/use-cases/modules.ts:
--------------------------------------------------------------------------------
1 | import {
2 | getModules,
3 | getModuleByTitle,
4 | createModule,
5 | getModulesWithSegments,
6 | reorderModules,
7 | deleteModule,
8 | } from "~/data-access/modules";
9 | import type { ModuleCreate } from "~/db/schema";
10 |
11 | export async function getModulesUseCase() {
12 | return getModules();
13 | }
14 |
15 | export async function createModuleUseCase(module: ModuleCreate) {
16 | return createModule(module);
17 | }
18 |
19 | export async function getOrCreateModuleUseCase(title: string) {
20 | const existingModule = await getModuleByTitle(title);
21 | if (existingModule) {
22 | return existingModule;
23 | }
24 |
25 | // Get all modules to determine the next order number
26 | const modules = await getModules();
27 | const maxOrder = modules.reduce(
28 | (max, module) => Math.max(max, module.order),
29 | -1
30 | );
31 | const nextOrder = maxOrder + 1;
32 |
33 | return createModule({ title, order: nextOrder });
34 | }
35 |
36 | export async function getModulesWithSegmentsUseCase() {
37 | const modulesWithSegments = await getModulesWithSegments();
38 |
39 | // Sort segments within each module by order
40 | return modulesWithSegments.map((module) => ({
41 | ...module,
42 | segments: module.segments.sort((a, b) => a.order - b.order),
43 | }));
44 | }
45 |
46 | export async function reorderModulesUseCase(
47 | updates: { id: number; order: number }[]
48 | ) {
49 | return reorderModules(updates);
50 | }
51 |
52 | export async function deleteModuleUseCase(moduleId: number) {
53 | return deleteModule(moduleId);
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/DefaultCatchBoundary.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ErrorComponent,
3 | Link,
4 | rootRouteId,
5 | useMatch,
6 | useRouter,
7 | } from '@tanstack/react-router'
8 | import type { ErrorComponentProps } from '@tanstack/react-router'
9 |
10 | export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
11 | const router = useRouter()
12 | const isRoot = useMatch({
13 | strict: false,
14 | select: (state) => state.id === rootRouteId,
15 | })
16 |
17 | console.error(error)
18 |
19 | return (
20 |
21 |
22 |
23 | {
25 | router.invalidate()
26 | }}
27 | className={`px-2 py-1 bg-gray-600 dark:bg-gray-700 rounded text-white uppercase font-extrabold`}
28 | >
29 | Try Again
30 |
31 | {isRoot ? (
32 |
36 | Home
37 |
38 | ) : (
39 | {
43 | e.preventDefault()
44 | window.history.back()
45 | }}
46 | >
47 | Go Back
48 |
49 | )}
50 |
51 |
52 | )
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/session.ts:
--------------------------------------------------------------------------------
1 | import { type UserId } from "~/use-cases/types";
2 | import { createSession, generateSessionToken, validateRequest } from "./auth";
3 | import { AuthenticationError } from "~/use-cases/errors";
4 | import { getCookie, setCookie } from "@tanstack/react-start/server";
5 | import { env } from "./env";
6 |
7 | const SESSION_COOKIE_NAME = "session";
8 |
9 | export async function setSessionTokenCookie(
10 | token: string,
11 | expiresAt: Date
12 | ): Promise {
13 | setCookie(SESSION_COOKIE_NAME, token, {
14 | httpOnly: true,
15 | sameSite: "lax",
16 | secure: env.NODE_ENV === "production",
17 | expires: expiresAt,
18 | path: "/",
19 | });
20 | }
21 |
22 | export async function deleteSessionTokenCookie(): Promise {
23 | setCookie(SESSION_COOKIE_NAME, "", {
24 | httpOnly: true,
25 | sameSite: "lax",
26 | secure: env.NODE_ENV === "production",
27 | maxAge: 0,
28 | path: "/",
29 | });
30 | }
31 |
32 | export async function getSessionToken(): Promise {
33 | const sessionCookie = getCookie(SESSION_COOKIE_NAME);
34 | return sessionCookie;
35 | }
36 |
37 | export const getCurrentUser = async () => {
38 | const { user } = await validateRequest();
39 | return user ?? undefined;
40 | };
41 |
42 | export const assertAuthenticated = async () => {
43 | const user = await getCurrentUser();
44 | if (!user) {
45 | throw new AuthenticationError();
46 | }
47 | return user;
48 | };
49 |
50 | export async function setSession(userId: UserId) {
51 | const token = generateSessionToken();
52 | const session = await createSession(token, userId);
53 | await setSessionTokenCookie(token, session.expiresAt);
54 | }
55 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/add-segment/server-functions.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { z } from "zod";
3 | import { adminMiddleware, authenticatedMiddleware } from "~/lib/auth";
4 | import { addSegmentUseCase } from "~/use-cases/segments";
5 | import { getSegments } from "~/data-access/segments";
6 | import { getModulesUseCase } from "~/use-cases/modules";
7 |
8 | export const createSegmentFn = createServerFn()
9 | .middleware([adminMiddleware])
10 | .validator(
11 | z.object({
12 | title: z.string(),
13 | content: z.string().optional(),
14 | videoKey: z.string().optional(),
15 | slug: z.string(),
16 | moduleTitle: z.string(),
17 | length: z.string().optional(),
18 | isPremium: z.boolean(),
19 | })
20 | )
21 | .handler(async ({ data }) => {
22 | // Get all segments to determine the next order number
23 | const segments = await getSegments();
24 | const maxOrder = segments.reduce(
25 | (max, segment) => Math.max(max, segment.order),
26 | -1
27 | );
28 | const nextOrder = maxOrder + 1;
29 |
30 | const segment = await addSegmentUseCase({
31 | title: data.title,
32 | content: data.content,
33 | slug: data.slug,
34 | order: nextOrder,
35 | moduleTitle: data.moduleTitle,
36 | videoKey: data.videoKey,
37 | length: data.length,
38 | isPremium: data.isPremium,
39 | });
40 |
41 | return segment;
42 | });
43 |
44 | export const getUniqueModuleNamesFn = createServerFn()
45 | .middleware([authenticatedMiddleware])
46 | .handler(async () => {
47 | const modules = await getModulesUseCase();
48 | return modules.map(module => module.title);
49 | });
50 |
--------------------------------------------------------------------------------
/src/routes/api/stripe/webhook.ts:
--------------------------------------------------------------------------------
1 | import { createServerFileRoute } from "@tanstack/react-start/server";
2 | import { stripe } from "~/lib/stripe";
3 | import { updateUserToPremiumUseCase } from "~/use-cases/users";
4 | import { env } from "~/utils/env";
5 |
6 | const webhookSecret = env.STRIPE_WEBHOOK_SECRET!;
7 |
8 | export const ServerRoute = createServerFileRoute("/api/stripe/webhook").methods(
9 | {
10 | POST: async ({ request }) => {
11 | const sig = request.headers.get("stripe-signature");
12 | const payload = await request.text();
13 |
14 | try {
15 | const event = stripe.webhooks.constructEvent(
16 | payload,
17 | sig!,
18 | webhookSecret
19 | );
20 |
21 | switch (event.type) {
22 | case "checkout.session.completed": {
23 | const session = event.data.object;
24 | const userId = session.metadata?.userId;
25 |
26 | if (userId) {
27 | await updateUserToPremiumUseCase(parseInt(userId));
28 | console.log(`Updated user ${userId} to premium status`);
29 | }
30 |
31 | console.log("Payment successful:", session.id);
32 | break;
33 | }
34 | }
35 |
36 | return new Response(JSON.stringify({ received: true }), {
37 | headers: { "Content-Type": "application/json" },
38 | });
39 | } catch (err) {
40 | console.error("Webhook Error:", err);
41 | return new Response(
42 | JSON.stringify({ error: "Webhook handler failed" }),
43 | {
44 | status: 400,
45 | headers: { "Content-Type": "application/json" },
46 | }
47 | );
48 | }
49 | },
50 | }
51 | );
52 |
--------------------------------------------------------------------------------
/src/use-cases/users.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createUser,
3 | deleteUser,
4 | getUserByEmail,
5 | updateUserToPremium,
6 | } from "~/data-access/users";
7 | import { PublicError } from "./errors";
8 | import { GoogleUser, UserId, UserSession } from "./types";
9 | import { createProfile, getProfile } from "~/data-access/profiles";
10 | import { createAccountViaGoogle } from "~/data-access/accounts";
11 | import { getCurrentUser } from "~/utils/session";
12 | import { isAdmin } from "~/lib/auth";
13 |
14 | export async function deleteUserUseCase(
15 | authenticatedUser: UserSession,
16 | userToDeleteId: UserId
17 | ): Promise {
18 | if (authenticatedUser.id !== userToDeleteId) {
19 | throw new PublicError("You can only delete your own account");
20 | }
21 |
22 | await deleteUser(userToDeleteId);
23 | }
24 |
25 | export async function getUserProfileUseCase(userId: UserId) {
26 | const profile = await getProfile(userId);
27 |
28 | if (!profile) {
29 | throw new PublicError("User not found");
30 | }
31 |
32 | return profile;
33 | }
34 |
35 | export async function createGoogleUserUseCase(googleUser: GoogleUser) {
36 | let existingUser = await getUserByEmail(googleUser.email);
37 |
38 | if (!existingUser) {
39 | existingUser = await createUser(googleUser.email);
40 | }
41 |
42 | await createAccountViaGoogle(existingUser.id, googleUser.sub);
43 |
44 | await createProfile(existingUser.id, googleUser.name, googleUser.picture);
45 |
46 | return existingUser.id;
47 | }
48 |
49 | export async function isAdminUseCase() {
50 | const user = await getCurrentUser();
51 | if (!user) {
52 | return false;
53 | }
54 | return isAdmin(user);
55 | }
56 |
57 | export async function updateUserToPremiumUseCase(userId: UserId) {
58 | await updateUserToPremium(userId);
59 | }
60 |
--------------------------------------------------------------------------------
/src/routes/api/segments/$segmentId/video.ts:
--------------------------------------------------------------------------------
1 | import { json } from "@tanstack/react-start";
2 | import { createServerFileRoute } from "@tanstack/react-start/server";
3 | import { AuthenticationError } from "~/use-cases/errors";
4 | import { getSegmentByIdUseCase } from "~/use-cases/segments";
5 | import { getAuthenticatedUser } from "~/utils/auth";
6 | import { getStorage } from "~/utils/storage";
7 |
8 | export const ServerRoute = createServerFileRoute(
9 | "/api/segments/$segmentId/video"
10 | ).methods({
11 | GET: async ({ request, params }) => {
12 | // Validate access
13 | const user = await getAuthenticatedUser();
14 |
15 | const segmentId = Number(params.segmentId);
16 | if (isNaN(segmentId)) throw new Error("Invalid segment ID");
17 |
18 | const segment = await getSegmentByIdUseCase(segmentId);
19 | if (!segment) throw new Error("Segment not found");
20 | if (!segment.videoKey) throw new Error("Video not attached to segment");
21 |
22 | if (segment.isPremium) {
23 | if (!user) throw new AuthenticationError();
24 | if (!user.isPremium && !user.isAdmin) {
25 | throw new Error("You don't have permission to access this video");
26 | }
27 | }
28 |
29 | const { storage } = getStorage();
30 |
31 | const rangeHeader = request.headers.get("range");
32 |
33 | const { stream, contentLength, contentType, contentRange } =
34 | await storage.getStream(segment.videoKey, rangeHeader);
35 |
36 | return new Response(stream, {
37 | headers: {
38 | "Content-Type": contentType,
39 | "Content-Length": contentLength.toString(),
40 | "Accept-Ranges": "bytes",
41 | "Cache-Control": "public, max-age=31536000, immutable",
42 | ...(contentRange ? { "Content-Range": contentRange } : {}),
43 | },
44 | });
45 | },
46 | });
47 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/edit-segment/server-functions.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { z } from "zod";
3 | import { adminMiddleware, authenticatedMiddleware } from "~/lib/auth";
4 | import {
5 | getSegmentBySlugUseCase,
6 | updateSegmentUseCase,
7 | } from "~/use-cases/segments";
8 | import { getModuleById } from "~/data-access/modules";
9 | import { getModulesUseCase } from "~/use-cases/modules";
10 |
11 | export const updateSegmentFn = createServerFn()
12 | .middleware([adminMiddleware])
13 | .validator(
14 | z.object({
15 | segmentId: z.number(),
16 | updates: z.object({
17 | title: z.string(),
18 | content: z.string().optional(),
19 | videoKey: z.string().optional(),
20 | moduleTitle: z.string(),
21 | slug: z.string(),
22 | length: z.string().optional(),
23 | isPremium: z.boolean(),
24 | }),
25 | })
26 | )
27 | .handler(async ({ data }) => {
28 | const { segmentId, updates } = data;
29 | return updateSegmentUseCase(segmentId, updates);
30 | });
31 |
32 | export const getSegmentFn = createServerFn()
33 | .middleware([authenticatedMiddleware])
34 | .validator(z.object({ slug: z.string() }))
35 | .handler(async ({ data }) => {
36 | const segment = await getSegmentBySlugUseCase(data.slug);
37 | if (!segment) throw new Error("Segment not found");
38 |
39 | const module = await getModuleById(segment.moduleId);
40 | if (!module) throw new Error("Module not found");
41 |
42 | return { segment: { ...segment, moduleTitle: module.title } };
43 | });
44 |
45 | export const getUniqueModuleNamesFn = createServerFn()
46 | .middleware([authenticatedMiddleware])
47 | .handler(async () => {
48 | const modules = await getModulesUseCase();
49 | return modules.map(module => module.title);
50 | });
51 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/video-header.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "~/components/ui/badge";
2 | import { BookOpen, Clock, Lock } from "lucide-react";
3 | import { type Segment } from "~/db/schema";
4 | import { AdminControls } from "./admin-controls";
5 |
6 | interface VideoHeaderProps {
7 | currentSegment: Segment;
8 | isAdmin: boolean;
9 | }
10 |
11 | export function VideoHeader({ currentSegment, isAdmin }: VideoHeaderProps) {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | {currentSegment.title}
22 |
23 | {currentSegment.length && (
24 |
25 |
26 | {currentSegment.length}
27 |
28 | )}
29 |
30 |
31 |
32 |
33 | {isAdmin && currentSegment.isPremium && (
34 |
38 |
39 | PREMIUM
40 |
41 | )}
42 |
43 | {isAdmin &&
}
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/src/routes/learn/add.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { z } from "zod";
3 | import { assertAuthenticatedFn } from "~/fn/auth";
4 | import {
5 | getUniqueModuleNamesFn,
6 | AddSegmentHeader,
7 | useAddSegment,
8 | } from "./-components/add-segment";
9 | import { Container } from "./-components/container";
10 | import { SegmentForm } from "./-components/segment-form";
11 | import { Plus } from "lucide-react";
12 |
13 | const addSegmentSearchSchema = z.object({
14 | moduleTitle: z.string().optional(),
15 | });
16 |
17 | export const Route = createFileRoute("/learn/add")({
18 | component: RouteComponent,
19 | beforeLoad: () => assertAuthenticatedFn(),
20 | validateSearch: addSegmentSearchSchema,
21 | loader: async () => {
22 | const moduleNames = await getUniqueModuleNamesFn();
23 | return { moduleNames };
24 | },
25 | });
26 |
27 | function RouteComponent() {
28 | const { moduleNames } = Route.useLoaderData();
29 | const search = Route.useSearch();
30 | const { onSubmit, isSubmitting, uploadProgress } = useAddSegment();
31 |
32 | return (
33 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # Bugs
2 |
3 | - when deleting a segment the app crashes (I think it's related to logic for redirecting the user to a previous segment)
4 | - when reordering modules, the navigation buttons do not seem to navigate the correct order of modules... are we sorting the modules by order? are we correctly updating the order on modules when we re-arrange them?
5 | - clicking on the delete module button expands the accordion but it should prevent default
6 | - it's not possible to dismiss the delete dialog when clicking outside the dialog, but I would think a better UX would be to allow it. research what's good UX and fix if it is an issue
7 |
8 | # Features
9 |
10 | - show confetti when a user completes a module
11 | - change "new video" to "complete module" when it's the last video in the module
12 | - add metric that users can see how many other users completed this segment (help drive more motivation maybe?)
13 | - add the ability for an admin to email all the premium users in the system to let them know new segments or modules are created. Add some type of MJLM editor / preview feature. This should use AWS SES. Provide a generic update template one can start editing. Hopefully support markdown to generate the MJML and send out the styled emails.
14 | - analytics for admins to see which students are finishing which segments
15 | - analytics for admins to get notifications when students comments on segments with a built in way to respond directly on the same notification page
16 | - analytics for when users view the site, sign up, and purchase by day
17 | - analytics on how the ratio between views, sign ups, premiums
18 | - gamification maybe? badges?
19 | - certificates on completion?
20 | - the ability to pick custom lucide-icons for the modules
21 | - editing the segment should change the nested layout, not go to a new page
22 | - experiment with having "new segment" show a modal with the form
23 | - load up a testimonial modal / dialog when clicking the testimonial button instead of a separate page.
24 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/edit.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { assertAuthenticatedFn } from "~/fn/auth";
3 | import {
4 | getUniqueModuleNamesFn,
5 | getSegmentFn,
6 | EditSegmentHeader,
7 | useEditSegment,
8 | } from "../-components/edit-segment";
9 | import { Container } from "../-components/container";
10 | import { SegmentForm } from "../-components/segment-form";
11 | import { Edit } from "lucide-react";
12 |
13 | export const Route = createFileRoute("/learn/$slug/edit")({
14 | component: RouteComponent,
15 | beforeLoad: () => assertAuthenticatedFn(),
16 | loader: async ({ params }) => {
17 | const { segment } = await getSegmentFn({ data: { slug: params.slug } });
18 | const moduleNames = await getUniqueModuleNamesFn();
19 | return { segment, moduleNames };
20 | },
21 | });
22 |
23 | function RouteComponent() {
24 | const { segment, moduleNames } = Route.useLoaderData();
25 | const { onSubmit, isSubmitting, uploadProgress } = useEditSegment(segment);
26 |
27 | return (
28 |
29 |
30 |
31 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/utils/storage/helpers.ts:
--------------------------------------------------------------------------------
1 | import { getPresignedUploadUrlFn } from "~/fn/storage";
2 | import { getVideoDuration, formatDuration } from "~/utils/video-duration";
3 |
4 | export interface UploadProgress {
5 | loaded: number;
6 | total: number;
7 | percentage: number;
8 | }
9 |
10 | export interface UploadResult {
11 | videoKey: string;
12 | duration: string; // formatted duration like "2:34"
13 | durationSeconds: number; // raw duration in seconds
14 | }
15 |
16 | export async function uploadVideoWithPresignedUrl(
17 | key: string,
18 | file: File,
19 | onProgress?: (progress: UploadProgress) => void
20 | ): Promise {
21 | // Calculate video duration first
22 | const durationSeconds = await getVideoDuration(file);
23 | const duration = formatDuration(durationSeconds);
24 |
25 | // Get presigned URL from server
26 | const { presignedUrl } = await getPresignedUploadUrlFn({
27 | data: { videoKey: key },
28 | });
29 |
30 | // Create XMLHttpRequest for progress tracking
31 | return new Promise((resolve, reject) => {
32 | const xhr = new XMLHttpRequest();
33 |
34 | xhr.upload.onprogress = event => {
35 | if (event.lengthComputable && onProgress) {
36 | const progress: UploadProgress = {
37 | loaded: event.loaded,
38 | total: event.total,
39 | percentage: Math.round((event.loaded / event.total) * 100),
40 | };
41 | onProgress(progress);
42 | }
43 | };
44 |
45 | xhr.onload = () => {
46 | if (xhr.status >= 200 && xhr.status < 300) {
47 | resolve({
48 | videoKey: key,
49 | duration,
50 | durationSeconds,
51 | });
52 | } else {
53 | reject(new Error(`Upload failed: ${xhr.statusText}`));
54 | }
55 | };
56 |
57 | xhr.onerror = () => {
58 | reject(new Error("Upload failed: Network error"));
59 | };
60 |
61 | xhr.open("PUT", presignedUrl);
62 | xhr.setRequestHeader("Content-Type", "video/mp4");
63 | xhr.send(file);
64 | });
65 | }
66 |
--------------------------------------------------------------------------------
/src/fn/comments.ts:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { authenticatedMiddleware, unauthenticatedMiddleware } from "~/lib/auth";
3 | import { z } from "zod";
4 | import {
5 | createComment,
6 | deleteComment,
7 | getComments,
8 | updateComment,
9 | } from "~/data-access/comments";
10 |
11 | export const getCommentsFn = createServerFn()
12 | .middleware([unauthenticatedMiddleware])
13 | .validator(z.object({ segmentId: z.number() }))
14 | .handler(async ({ data }) => {
15 | return getComments(data.segmentId);
16 | });
17 |
18 | const createCommentSchema = z.object({
19 | segmentId: z.number(),
20 | content: z.string(),
21 | parentId: z.number().nullable(),
22 | repliedToId: z.number().nullable(),
23 | });
24 |
25 | export type CreateCommentSchema = z.infer;
26 |
27 | export const createCommentFn = createServerFn({ method: "POST" })
28 | .middleware([authenticatedMiddleware])
29 | .validator(createCommentSchema)
30 | .handler(async ({ data, context }) => {
31 | return createComment({
32 | userId: context.userId,
33 | segmentId: data.segmentId,
34 | content: data.content,
35 | parentId: data.parentId,
36 | repliedToId: data.repliedToId,
37 | });
38 | });
39 |
40 | export const deleteCommentFn = createServerFn({ method: "POST" })
41 | .middleware([authenticatedMiddleware])
42 | .validator(z.object({ commentId: z.number() }))
43 | .handler(async ({ data, context }) => {
44 | return deleteComment(data.commentId, context.userId);
45 | });
46 |
47 | const updateCommentSchema = z.object({
48 | commentId: z.number(),
49 | content: z.string(),
50 | });
51 |
52 | export type UpdateCommentSchema = z.infer;
53 |
54 | export const updateCommentFn = createServerFn({ method: "POST" })
55 | .middleware([authenticatedMiddleware])
56 | .validator(updateCommentSchema)
57 | .handler(async ({ data, context }) => {
58 | return updateComment(data.commentId, data.content, context.userId);
59 | });
60 |
--------------------------------------------------------------------------------
/src/data-access/modules.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import { modules, segments } from "~/db/schema";
3 | import { eq } from "drizzle-orm";
4 | import type { Module, ModuleCreate } from "~/db/schema";
5 |
6 | export async function getModules() {
7 | return database.select().from(modules).orderBy(modules.order);
8 | }
9 |
10 | export async function getModuleById(id: Module["id"]) {
11 | const result = await database.query.modules.findFirst({
12 | where: eq(modules.id, id),
13 | });
14 | return result;
15 | }
16 |
17 | export async function createModule(module: ModuleCreate) {
18 | const result = await database.insert(modules).values(module).returning();
19 | return result[0];
20 | }
21 |
22 | export async function getModuleByTitle(title: string) {
23 | const result = await database
24 | .select()
25 | .from(modules)
26 | .where(eq(modules.title, title))
27 | .limit(1);
28 | return result[0];
29 | }
30 |
31 | export async function getModulesWithSegments() {
32 | return database.query.modules.findMany({
33 | with: { segments: true },
34 | orderBy: modules.order,
35 | });
36 | }
37 |
38 | export async function updateModuleOrder(moduleId: number, newOrder: number) {
39 | return database
40 | .update(modules)
41 | .set({ order: newOrder, updatedAt: new Date() })
42 | .where(eq(modules.id, moduleId))
43 | .returning();
44 | }
45 |
46 | export async function reorderModules(updates: { id: number; order: number }[]) {
47 | // Use a transaction to ensure all updates happen together
48 | return database.transaction(async (tx) => {
49 | const results = [];
50 | for (const update of updates) {
51 | const [result] = await tx
52 | .update(modules)
53 | .set({ order: update.order, updatedAt: new Date() })
54 | .where(eq(modules.id, update.id))
55 | .returning();
56 | results.push(result);
57 | }
58 | return results;
59 | });
60 | }
61 |
62 | export async function deleteModule(moduleId: number) {
63 | return database.delete(modules).where(eq(modules.id, moduleId));
64 | }
65 |
--------------------------------------------------------------------------------
/src/hooks/mutations/use-create-comment.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { createCommentFn, CreateCommentSchema } from "~/fn/comments";
3 | import { getCommentsQuery } from "~/lib/queries/comments";
4 | import { useLoaderData } from "@tanstack/react-router";
5 | import { CommentsWithUser } from "~/data-access/comments";
6 | import { useAuth } from "../use-auth";
7 |
8 | export function useCreateComment() {
9 | const queryClient = useQueryClient();
10 | const user = useAuth();
11 | const { segment } = useLoaderData({ from: "/learn/$slug/_layout/" });
12 | return useMutation({
13 | mutationFn: (variables: CreateCommentSchema) =>
14 | createCommentFn({ data: variables }),
15 | onSuccess: () => {
16 | queryClient.invalidateQueries(getCommentsQuery(segment.id));
17 | },
18 | onMutate: (variables) => {
19 | if (!user || !navigator.onLine) throw new Error("Something went wrong");
20 | const previousComments = queryClient.getQueryData(
21 | getCommentsQuery(segment.id).queryKey
22 | );
23 | const newComment: CommentsWithUser[number] = {
24 | id: Math.random(),
25 | content: variables.content,
26 | userId: user.id,
27 | profile: {
28 | id: user.id,
29 | displayName: user.email,
30 | image: null,
31 | userId: user.id,
32 | imageId: null,
33 | bio: "",
34 | },
35 | segmentId: segment.id,
36 | createdAt: new Date(),
37 | updatedAt: new Date(),
38 | parentId: variables.parentId ?? null,
39 | repliedToId: variables.repliedToId ?? null,
40 | children: [],
41 | };
42 | queryClient.setQueryData(getCommentsQuery(segment.id).queryKey, (old) => [
43 | newComment,
44 | ...(old ?? []),
45 | ]);
46 | return { previousComments };
47 | },
48 | onError: (_, __, context) => {
49 | queryClient.setQueryData(
50 | getCommentsQuery(segment.id).queryKey,
51 | context?.previousComments
52 | );
53 | },
54 | });
55 | }
56 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/add-segment/use-add-segment.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useNavigate } from "@tanstack/react-router";
3 | import { createSegmentFn } from "./server-functions";
4 | import { type SegmentFormValues } from "../segment-form";
5 | import {
6 | uploadVideoWithPresignedUrl,
7 | type UploadProgress,
8 | } from "~/utils/storage/helpers";
9 | import { generateRandomUUID } from "~/utils/uuid";
10 | import { usePreventTabClose } from "~/hooks/use-prevent-tab-close";
11 |
12 | export function useAddSegment() {
13 | const navigate = useNavigate();
14 | const [isSubmitting, setIsSubmitting] = useState(false);
15 | const [uploadProgress, setUploadProgress] = useState(
16 | null
17 | );
18 |
19 | usePreventTabClose(isSubmitting);
20 |
21 | const onSubmit = async (values: SegmentFormValues) => {
22 | try {
23 | setIsSubmitting(true);
24 | let videoKey;
25 | let videoDuration;
26 |
27 | if (values.video) {
28 | videoKey = `${generateRandomUUID()}.mp4`;
29 | const uploadResult = await uploadVideoWithPresignedUrl(
30 | videoKey,
31 | values.video,
32 | progress => setUploadProgress(progress)
33 | );
34 | videoDuration = uploadResult.duration;
35 | }
36 |
37 | const segment = await createSegmentFn({
38 | data: {
39 | title: values.title,
40 | content: values.content,
41 | slug: values.slug,
42 | moduleTitle: values.moduleTitle,
43 | length: videoDuration,
44 | videoKey,
45 | isPremium: values.isPremium,
46 | },
47 | });
48 |
49 | // Navigate to the new segment
50 | navigate({ to: "/learn/$slug", params: { slug: segment.slug } });
51 | } catch (error) {
52 | console.error("Failed to create segment:", error);
53 | // TODO: Show error toast
54 | } finally {
55 | setIsSubmitting(false);
56 | setUploadProgress(null);
57 | }
58 | };
59 |
60 | return {
61 | onSubmit,
62 | isSubmitting,
63 | uploadProgress,
64 | };
65 | }
66 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/mobile-navigation-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
3 | import { Menu } from "lucide-react";
4 | import { Skeleton } from "~/components/ui/skeleton";
5 |
6 | export function MobileNavigationSkeleton() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 | Toggle menu
21 |
22 |
23 |
24 |
25 | {/* Skeleton for mobile navigation content */}
26 | {Array.from({ length: 3 }).map((_, index) => (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ))}
40 |
41 |
42 |
43 |
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ))
42 | CardTitle.displayName = "CardTitle"
43 |
44 | const CardDescription = React.forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ))
54 | CardDescription.displayName = "CardDescription"
55 |
56 | const CardContent = React.forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ))
62 | CardContent.displayName = "CardContent"
63 |
64 | const CardFooter = React.forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ))
74 | CardFooter.displayName = "CardFooter"
75 |
76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { createMiddleware } from "@tanstack/react-start";
2 | import { validateRequest } from "~/utils/auth";
3 | import { redirect } from "@tanstack/react-router";
4 | import { type User } from "~/db/schema";
5 |
6 | export function isAdmin(user: User | null) {
7 | return user?.isAdmin ?? false;
8 | }
9 |
10 | export const logMiddleware = createMiddleware({ type: "function" }).server(
11 | async ({ next, context, functionId }) => {
12 | const now = Date.now();
13 |
14 | const result = await next();
15 |
16 | const duration = Date.now() - now;
17 | console.log("Server Req/Res:", { duration: `${duration}ms`, functionId });
18 |
19 | return result;
20 | }
21 | );
22 |
23 | export const authenticatedMiddleware = createMiddleware({ type: "function" })
24 | .middleware([logMiddleware])
25 | .server(async ({ next }) => {
26 | const { user } = await validateRequest();
27 |
28 | if (!user) {
29 | throw redirect({ to: "/unauthenticated" });
30 | }
31 |
32 | return next({
33 | context: { userId: user.id, isAdmin: isAdmin(user), email: user.email },
34 | });
35 | });
36 |
37 | export const adminMiddleware = createMiddleware({ type: "function" })
38 | .middleware([logMiddleware])
39 | .server(async ({ next }) => {
40 | const { user } = await validateRequest();
41 |
42 | if (!user) {
43 | throw redirect({ to: "/unauthenticated" });
44 | }
45 |
46 | if (!isAdmin(user)) {
47 | throw redirect({ to: "/unauthorized" });
48 | }
49 |
50 | return next({ context: { userId: user.id } });
51 | });
52 |
53 | export const userIdMiddleware = createMiddleware({ type: "function" })
54 | .middleware([logMiddleware])
55 | .server(async ({ next }) => {
56 | const { user } = await validateRequest();
57 |
58 | return next({ context: { userId: user?.id } });
59 | });
60 |
61 | export const unauthenticatedMiddleware = createMiddleware({ type: "function" })
62 | .middleware([logMiddleware])
63 | .server(async ({ next }) => {
64 | const { user } = await validateRequest();
65 |
66 | return next({
67 | context: { userId: user?.id, isAdmin: isAdmin(user), user },
68 | });
69 | });
70 |
--------------------------------------------------------------------------------
/src/hooks/use-newsletter-subscription.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import { publicEnv } from "~/utils/env-public";
3 | import { subscribeFn } from "~/routes/-components/newsletter";
4 |
5 | declare global {
6 | interface Window {
7 | grecaptcha: any;
8 | }
9 | }
10 |
11 | export function useNewsletterSubscription() {
12 | const [email, setEmail] = useState("");
13 | const [isSubmitted, setIsSubmitted] = useState(false);
14 | const [isLoading, setIsLoading] = useState(false);
15 | const [error, setError] = useState(null);
16 |
17 | useEffect(() => {
18 | const script = document.createElement("script");
19 | script.src = `https://www.google.com/recaptcha/api.js?render=${publicEnv.VITE_RECAPTCHA_KEY}`;
20 | script.async = true;
21 | document.head.appendChild(script);
22 |
23 | return () => {
24 | document.head.removeChild(script);
25 | };
26 | }, []);
27 |
28 | const handleSubmit = async (e: React.FormEvent) => {
29 | e.preventDefault();
30 | setIsLoading(true);
31 | setError(null);
32 |
33 | try {
34 | await new Promise((resolve, reject) => {
35 | window.grecaptcha.ready(function () {
36 | window.grecaptcha
37 | .execute(publicEnv.VITE_RECAPTCHA_KEY, { action: "submit" })
38 | .then(async function (token: string) {
39 | try {
40 | await subscribeFn({
41 | data: {
42 | email,
43 | recaptchaToken: token,
44 | },
45 | });
46 | setIsSubmitted(true);
47 | resolve();
48 | } catch (error) {
49 | reject(error);
50 | }
51 | })
52 | .catch(reject);
53 | });
54 | });
55 | } catch (error) {
56 | console.error("Newsletter subscription error:", error);
57 | setError("Failed to subscribe. Please try again.");
58 | } finally {
59 | setIsLoading(false);
60 | }
61 | };
62 |
63 | return {
64 | email,
65 | setEmail,
66 | isSubmitted,
67 | isLoading,
68 | error,
69 | handleSubmit,
70 | };
71 | }
72 |
--------------------------------------------------------------------------------
/src/routes/learn/index.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute, Link } from "@tanstack/react-router";
2 | import { createServerFn } from "@tanstack/react-start";
3 | import { unauthenticatedMiddleware } from "~/lib/auth";
4 | import { getSegments } from "~/data-access/segments";
5 | import { Button } from "~/components/ui/button";
6 | import { isAdminFn } from "~/fn/auth";
7 |
8 | const getSegmentsFn = createServerFn()
9 | .middleware([unauthenticatedMiddleware])
10 | .handler(async () => {
11 | return await getSegments();
12 | });
13 |
14 | export const Route = createFileRoute("/learn/")({
15 | component: RouteComponent,
16 | loader: async () => {
17 | const isAdmin = await isAdminFn();
18 | const segments = await getSegmentsFn();
19 | return { isAdmin, segments };
20 | },
21 | });
22 |
23 | function RouteComponent() {
24 | const { isAdmin, segments } = Route.useLoaderData();
25 |
26 | if (segments.length === 0) {
27 | return (
28 |
29 |
30 | No Learning Content Available
31 |
32 |
33 | {isAdmin
34 | ? "You have not added any learning content yet. Get started by creating your first module!"
35 | : "The course is still under development. Please check back later."}
36 |
37 | {isAdmin ? (
38 |
39 | Create a Module
40 |
41 | ) : (
42 |
43 | Back to Home
44 |
45 | )}
46 |
47 | );
48 | }
49 |
50 | return (
51 |
52 |
Welcome to the Course
53 |
54 | Ready to start learning? Click below to begin with the first lesson.
55 |
56 |
57 |
58 | Start Learning
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/edit-segment/use-edit-segment.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useNavigate, useParams } from "@tanstack/react-router";
3 | import { updateSegmentFn } from "./server-functions";
4 | import { type SegmentFormValues } from "../segment-form";
5 | import {
6 | uploadVideoWithPresignedUrl,
7 | type UploadProgress,
8 | } from "~/utils/storage/helpers";
9 | import { generateRandomUUID } from "~/utils/uuid";
10 | import { usePreventTabClose } from "~/hooks/use-prevent-tab-close";
11 |
12 | export function useEditSegment(segment: any) {
13 | const navigate = useNavigate();
14 | const params = useParams({ from: "/learn/$slug/edit" });
15 | const slug = params.slug;
16 | const [isSubmitting, setIsSubmitting] = useState(false);
17 | const [uploadProgress, setUploadProgress] = useState(
18 | null
19 | );
20 |
21 | usePreventTabClose(isSubmitting);
22 |
23 | const onSubmit = async (values: SegmentFormValues) => {
24 | try {
25 | setIsSubmitting(true);
26 | let videoKey = undefined;
27 | let videoDuration = segment.length || undefined; // Keep existing length if no new video
28 |
29 | if (values.video) {
30 | videoKey = `${generateRandomUUID()}.mp4`;
31 | const uploadResult = await uploadVideoWithPresignedUrl(
32 | videoKey,
33 | values.video,
34 | (progress) => setUploadProgress(progress)
35 | );
36 | videoDuration = uploadResult.duration;
37 | }
38 |
39 | await updateSegmentFn({
40 | data: {
41 | segmentId: segment.id,
42 | updates: {
43 | title: values.title,
44 | content: values.content,
45 | videoKey: videoKey,
46 | moduleTitle: values.moduleTitle,
47 | slug: values.slug,
48 | length: videoDuration,
49 | isPremium: values.isPremium,
50 | },
51 | },
52 | });
53 |
54 | // Navigate back to the segment
55 | navigate({ to: "/learn/$slug", params: { slug } });
56 | } catch (error) {
57 | console.error("Failed to update segment:", error);
58 | // TODO: Show error toast
59 | } finally {
60 | setIsSubmitting(false);
61 | setUploadProgress(null);
62 | }
63 | };
64 |
65 | return {
66 | onSubmit,
67 | isSubmitting,
68 | uploadProgress,
69 | };
70 | }
71 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/navigation-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Sidebar,
3 | SidebarContent,
4 | SidebarGroup,
5 | SidebarGroupContent,
6 | SidebarGroupLabel,
7 | SidebarMenu,
8 | } from "~/components/ui/sidebar";
9 | import { Skeleton } from "~/components/ui/skeleton";
10 |
11 | export function NavigationSkeleton() {
12 | return (
13 |
14 |
15 |
16 |
17 | Course Content
18 |
19 |
20 |
21 |
22 | {/* Skeleton for 3-4 modules */}
23 | {Array.from({ length: 3 }).map((_, index) => (
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | ))}
48 |
49 |
50 |
51 |
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to TanStack.com!
2 |
3 | This site is built with TanStack Router!
4 |
5 | - [TanStack Router Docs](https://tanstack.com/router)
6 |
7 | It's deployed automagically with Netlify!
8 |
9 | - [Netlify](https://netlify.com/)
10 |
11 | ## Development
12 |
13 | From your terminal:
14 |
15 | ```sh
16 | pnpm install
17 | pnpm dev
18 | ```
19 |
20 | This starts your app in development mode, rebuilding assets on file changes.
21 |
22 | ## Editing and previewing the docs of TanStack projects locally
23 |
24 | The documentations for all TanStack projects except for `React Charts` are hosted on [https://tanstack.com](https://tanstack.com), powered by this TanStack Router app.
25 | In production, the markdown doc pages are fetched from the GitHub repos of the projects, but in development they are read from the local file system.
26 |
27 | Follow these steps if you want to edit the doc pages of a project (in these steps we'll assume it's [`TanStack/form`](https://github.com/tanstack/form)) and preview them locally :
28 |
29 | 1. Create a new directory called `tanstack`.
30 |
31 | ```sh
32 | mkdir tanstack
33 | ```
34 |
35 | 2. Enter the directory and clone this repo and the repo of the project there.
36 |
37 | ```sh
38 | cd tanstack
39 | git clone git@github.com:TanStack/tanstack.com.git
40 | git clone git@github.com:TanStack/form.git
41 | ```
42 |
43 | > [!NOTE]
44 | > Your `tanstack` directory should look like this:
45 | >
46 | > ```
47 | > tanstack/
48 | > |
49 | > +-- form/
50 | > |
51 | > +-- tanstack.com/
52 | > ```
53 |
54 | > [!WARNING]
55 | > Make sure the name of the directory in your local file system matches the name of the project's repo. For example, `tanstack/form` must be cloned into `form` (this is the default) instead of `some-other-name`, because that way, the doc pages won't be found.
56 |
57 | 3. Enter the `tanstack/tanstack.com` directory, install the dependencies and run the app in dev mode:
58 |
59 | ```sh
60 | cd tanstack.com
61 | pnpm i
62 | # The app will run on https://localhost:3000 by default
63 | pnpm dev
64 | ```
65 |
66 | 4. Now you can visit http://localhost:3000/form/latest/docs/overview in the browser and see the changes you make in `tanstack/form/docs`.
67 |
68 | > [!NOTE]
69 | > The updated pages need to be manually reloaded in the browser.
70 |
71 | > [!WARNING]
72 | > You will need to update the `docs/config.json` file (in the project's repo) if you add a new doc page!
73 |
--------------------------------------------------------------------------------
/src/routes/learn/$slug/-components/video-content-tabs-panel.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { FileText, MessageSquare } from "lucide-react";
3 | import { cn } from "~/lib/utils";
4 | import { type Segment } from "~/db/schema";
5 | import { ContentPanel } from "./content-panel";
6 | import { CommentsPanel } from "./comments-panel";
7 |
8 | interface VideoContentTabsPanelProps {
9 | currentSegment: Segment;
10 | isLoggedIn: boolean;
11 | }
12 |
13 | export function VideoContentTabsPanel({
14 | currentSegment,
15 | isLoggedIn,
16 | }: VideoContentTabsPanelProps) {
17 | const [activeTab, setActiveTab] = useState<"content" | "comments">("content");
18 |
19 | return (
20 |
21 | {/* Tab Headers */}
22 |
23 | setActiveTab("content")}
25 | className={cn(
26 | "flex items-center gap-2 px-6 py-4 text-sm font-medium transition-all duration-200 border-b-2 cursor-pointer",
27 | activeTab === "content"
28 | ? "border-theme-500 text-theme-600 dark:text-theme-400 bg-theme-50 dark:bg-theme-950/30"
29 | : "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
30 | )}
31 | >
32 |
33 | Lesson Content
34 |
35 | setActiveTab("comments")}
37 | className={cn(
38 | "flex items-center gap-2 px-6 py-4 text-sm font-medium transition-all duration-200 border-b-2 cursor-pointer",
39 | activeTab === "comments"
40 | ? "border-theme-500 text-theme-600 dark:text-theme-400 bg-theme-50 dark:bg-theme-950/30"
41 | : "border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
42 | )}
43 | >
44 |
45 | Discussion
46 |
47 |
48 |
49 | {/* Tab Content */}
50 |
51 | {activeTab === "content" && (
52 |
53 | )}
54 |
55 | {activeTab === "comments" && (
56 |
61 | )}
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/drizzle/0000_complex_harrier.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE "app_accounts" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "userId" serial NOT NULL,
4 | "googleId" text,
5 | CONSTRAINT "app_accounts_googleId_unique" UNIQUE("googleId")
6 | );
7 | --> statement-breakpoint
8 | CREATE TABLE "app_attachment" (
9 | "id" serial PRIMARY KEY NOT NULL,
10 | "segmentId" serial NOT NULL,
11 | "fileName" text NOT NULL,
12 | "fileKey" text NOT NULL,
13 | "created_at" timestamp DEFAULT now() NOT NULL
14 | );
15 | --> statement-breakpoint
16 | CREATE TABLE "app_profile" (
17 | "id" serial PRIMARY KEY NOT NULL,
18 | "userId" serial NOT NULL,
19 | "displayName" text,
20 | "imageId" text,
21 | "image" text,
22 | "bio" text DEFAULT '' NOT NULL,
23 | CONSTRAINT "app_profile_userId_unique" UNIQUE("userId")
24 | );
25 | --> statement-breakpoint
26 | CREATE TABLE "app_segment" (
27 | "id" serial PRIMARY KEY NOT NULL,
28 | "title" text NOT NULL,
29 | "content" text NOT NULL,
30 | "order" integer NOT NULL,
31 | "moduleId" text NOT NULL,
32 | "videoKey" text,
33 | "created_at" timestamp DEFAULT now() NOT NULL,
34 | "updated_at" timestamp DEFAULT now() NOT NULL
35 | );
36 | --> statement-breakpoint
37 | CREATE TABLE "app_session" (
38 | "id" text PRIMARY KEY NOT NULL,
39 | "userId" serial NOT NULL,
40 | "expires_at" timestamp with time zone NOT NULL
41 | );
42 | --> statement-breakpoint
43 | CREATE TABLE "app_user" (
44 | "id" serial PRIMARY KEY NOT NULL,
45 | "email" text,
46 | "emailVerified" timestamp,
47 | CONSTRAINT "app_user_email_unique" UNIQUE("email")
48 | );
49 | --> statement-breakpoint
50 | ALTER TABLE "app_accounts" ADD CONSTRAINT "app_accounts_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
51 | ALTER TABLE "app_attachment" ADD CONSTRAINT "app_attachment_segmentId_app_segment_id_fk" FOREIGN KEY ("segmentId") REFERENCES "public"."app_segment"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
52 | ALTER TABLE "app_profile" ADD CONSTRAINT "app_profile_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
53 | ALTER TABLE "app_session" ADD CONSTRAINT "app_session_userId_app_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."app_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
54 | CREATE INDEX "user_id_google_id_idx" ON "app_accounts" USING btree ("userId","googleId");--> statement-breakpoint
55 | CREATE INDEX "sessions_user_id_idx" ON "app_session" USING btree ("userId");
--------------------------------------------------------------------------------
/src/routes/learn/-components/course-segments.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | SidebarGroup,
3 | SidebarGroupContent,
4 | SidebarGroupLabel,
5 | SidebarMenu,
6 | SidebarMenuItem,
7 | SidebarMenuButton,
8 | } from "~/components/ui/sidebar";
9 |
10 | interface Segment {
11 | id: string;
12 | title: string;
13 | }
14 |
15 | interface CourseSegmentsProps {
16 | segments: Segment[];
17 | currentSegmentId: string;
18 | variant?: "mobile" | "desktop";
19 | }
20 |
21 | export function CourseSegments({
22 | segments,
23 | currentSegmentId,
24 | variant = "desktop",
25 | }: CourseSegmentsProps) {
26 | if (variant === "mobile") {
27 | return (
28 |
44 | );
45 | }
46 |
47 | return (
48 |
49 |
50 | Sections
51 |
52 |
53 |
54 | {segments.map((segment) => (
55 |
56 |
61 |
65 |
66 | {segment.id}
67 |
68 | {segment.title}
69 |
70 |
71 |
72 | ))}
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/drizzle/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1742522864908,
9 | "tag": "0000_complex_harrier",
10 | "breakpoints": true
11 | },
12 | {
13 | "idx": 1,
14 | "version": "7",
15 | "when": 1742527853040,
16 | "tag": "0001_military_silver_samurai",
17 | "breakpoints": true
18 | },
19 | {
20 | "idx": 2,
21 | "version": "7",
22 | "when": 1742531993457,
23 | "tag": "0002_neat_mole_man",
24 | "breakpoints": true
25 | },
26 | {
27 | "idx": 3,
28 | "version": "7",
29 | "when": 1742532086395,
30 | "tag": "0003_lively_invisible_woman",
31 | "breakpoints": true
32 | },
33 | {
34 | "idx": 4,
35 | "version": "7",
36 | "when": 1742569097290,
37 | "tag": "0004_careful_jamie_braddock",
38 | "breakpoints": true
39 | },
40 | {
41 | "idx": 5,
42 | "version": "7",
43 | "when": 1742653511648,
44 | "tag": "0005_puzzling_random",
45 | "breakpoints": true
46 | },
47 | {
48 | "idx": 6,
49 | "version": "7",
50 | "when": 1742658542047,
51 | "tag": "0006_right_sunfire",
52 | "breakpoints": true
53 | },
54 | {
55 | "idx": 7,
56 | "version": "7",
57 | "when": 1743122968712,
58 | "tag": "0007_loose_maximus",
59 | "breakpoints": true
60 | },
61 | {
62 | "idx": 8,
63 | "version": "7",
64 | "when": 1743128900664,
65 | "tag": "0008_fat_thena",
66 | "breakpoints": true
67 | },
68 | {
69 | "idx": 9,
70 | "version": "7",
71 | "when": 1743137620459,
72 | "tag": "0009_wild_maria_hill",
73 | "breakpoints": true
74 | },
75 | {
76 | "idx": 10,
77 | "version": "7",
78 | "when": 1743707687250,
79 | "tag": "0010_red_maestro",
80 | "breakpoints": true
81 | },
82 | {
83 | "idx": 11,
84 | "version": "7",
85 | "when": 1753620741968,
86 | "tag": "0011_clear_christian_walker",
87 | "breakpoints": true
88 | },
89 | {
90 | "idx": 12,
91 | "version": "7",
92 | "when": 1753726415240,
93 | "tag": "0012_unusual_shiver_man",
94 | "breakpoints": true
95 | },
96 | {
97 | "idx": 13,
98 | "version": "7",
99 | "when": 1753730024504,
100 | "tag": "0013_black_vargas",
101 | "breakpoints": true
102 | }
103 | ]
104 | }
--------------------------------------------------------------------------------
/src/routes/api/login/google/callback/index.ts:
--------------------------------------------------------------------------------
1 | import { createServerFileRoute } from "@tanstack/react-start/server";
2 | import { OAuth2RequestError } from "arctic";
3 | import { getAccountByGoogleIdUseCase } from "~/use-cases/accounts";
4 | import { GoogleUser } from "~/use-cases/types";
5 | import { createGoogleUserUseCase } from "~/use-cases/users";
6 | import { googleAuth } from "~/utils/auth";
7 | import { setSession } from "~/utils/session";
8 | import { deleteCookie, getCookie } from "@tanstack/react-start/server";
9 |
10 | const AFTER_LOGIN_URL = "/";
11 |
12 | export const ServerRoute = createServerFileRoute(
13 | "/api/login/google/callback/"
14 | ).methods({
15 | GET: async ({ request }) => {
16 | const url = new URL(request.url);
17 | const code = url.searchParams.get("code");
18 | const state = url.searchParams.get("state");
19 | const storedState = getCookie("google_oauth_state") ?? null;
20 | const codeVerifier = getCookie("google_code_verifier") ?? null;
21 | const redirectUri = getCookie("google_redirect_uri") ?? AFTER_LOGIN_URL;
22 |
23 | if (
24 | !code ||
25 | !state ||
26 | !storedState ||
27 | state !== storedState ||
28 | !codeVerifier
29 | ) {
30 | return new Response(null, { status: 400 });
31 | }
32 |
33 | deleteCookie("google_oauth_state");
34 | deleteCookie("google_code_verifier");
35 | deleteCookie("google_redirect_uri");
36 |
37 | try {
38 | const tokens = await googleAuth.validateAuthorizationCode(
39 | code,
40 | codeVerifier
41 | );
42 | const response = await fetch(
43 | "https://openidconnect.googleapis.com/v1/userinfo",
44 | { headers: { Authorization: `Bearer ${tokens.accessToken()}` } }
45 | );
46 |
47 | const googleUser: GoogleUser = await response.json();
48 |
49 | const existingAccount = await getAccountByGoogleIdUseCase(googleUser.sub);
50 |
51 | if (existingAccount) {
52 | await setSession(existingAccount.userId);
53 | return new Response(null, {
54 | status: 302,
55 | headers: { Location: redirectUri },
56 | });
57 | }
58 |
59 | const userId = await createGoogleUserUseCase(googleUser);
60 |
61 | await setSession(userId);
62 |
63 | return new Response(null, {
64 | status: 302,
65 | headers: { Location: redirectUri },
66 | });
67 | } catch (e) {
68 | console.error(e);
69 | // the specific error message depends on the provider
70 | if (e instanceof OAuth2RequestError) {
71 | // invalid code
72 | return new Response(null, { status: 400 });
73 | }
74 | return new Response(null, { status: 500 });
75 | }
76 | },
77 | });
78 |
--------------------------------------------------------------------------------
/src/components/ui/combobox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Check, ChevronsUpDown } from "lucide-react";
3 | import { cn } from "~/lib/utils";
4 | import { Button } from "~/components/ui/button";
5 | import {
6 | Command,
7 | CommandEmpty,
8 | CommandGroup,
9 | CommandInput,
10 | CommandItem,
11 | } from "~/components/ui/command";
12 | import {
13 | Popover,
14 | PopoverContent,
15 | PopoverTrigger,
16 | } from "~/components/ui/popover";
17 |
18 | export interface ComboboxProps {
19 | options: string[];
20 | value: string;
21 | onChange: (value: string) => void;
22 | placeholder?: string;
23 | emptyText?: string;
24 | className?: string;
25 | }
26 |
27 | export function Combobox({
28 | options,
29 | value,
30 | onChange,
31 | placeholder = "Select or enter an option...",
32 | emptyText = "No results found.",
33 | className,
34 | }: ComboboxProps) {
35 | const [open, setOpen] = React.useState(false);
36 | const [inputValue, setInputValue] = React.useState(value);
37 |
38 | // Update input value when value prop changes
39 | React.useEffect(() => {
40 | setInputValue(value);
41 | }, [value]);
42 |
43 | return (
44 |
45 |
46 |
52 | {value || placeholder}
53 |
54 |
55 |
56 |
57 |
58 | {
62 | setInputValue(value);
63 | onChange(value);
64 | }}
65 | />
66 | {emptyText}
67 |
68 | {options.map((option) => (
69 | {
73 | onChange(option);
74 | setOpen(false);
75 | }}
76 | >
77 |
83 | {option}
84 |
85 | ))}
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/user-menu.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import { Separator } from "~/components/ui/separator";
3 | import { Home, LogOut, User } from "lucide-react";
4 | import { Link } from "@tanstack/react-router";
5 | import { useAuth } from "~/hooks/use-auth";
6 | import { useProfile } from "~/hooks/use-profile";
7 |
8 | interface UserMenuProps {
9 | className?: string;
10 | }
11 |
12 | export function UserMenu({ className }: UserMenuProps) {
13 | const user = useAuth();
14 | const { data: profile } = useProfile();
15 |
16 | if (!user) {
17 | return (
18 |
28 | );
29 | }
30 |
31 | return (
32 |
33 |
34 | {/* User Info */}
35 |
36 |
44 |
45 |
46 | {profile?.displayName}
47 |
48 | {user.isPremium && (
49 |
Premium Member
50 | )}
51 |
52 |
53 |
54 |
55 |
56 | {/* Navigation */}
57 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/routes/success.tsx:
--------------------------------------------------------------------------------
1 | import { createFileRoute } from "@tanstack/react-router";
2 | import { useFirstSegment } from "~/hooks/use-first-segment";
3 | import Confetti from "react-confetti";
4 | import { Button } from "~/components/ui/button";
5 | import { useEffect, useState } from "react";
6 | import { useWindowSize } from "~/hooks/use-window-size";
7 |
8 | export const Route = createFileRoute("/success")({ component: RouteComponent });
9 |
10 | function RouteComponent() {
11 | const [cofettiPieces, setCofettiPieces] = useState(100);
12 | const { data: firstSegment, isLoading } = useFirstSegment();
13 | const { width, height } = useWindowSize();
14 |
15 | useEffect(() => {
16 | const timeout = setTimeout(() => {
17 | setCofettiPieces(0);
18 | }, 5000);
19 |
20 | return () => clearTimeout(timeout);
21 | }, []);
22 |
23 | return (
24 |
25 | {/* Background gradient similar to homepage */}
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
49 |
50 | Payment Successful!
51 |
52 |
53 | Thank you for your purchase. Your order has been confirmed.
54 |
55 |
60 | {isLoading || !firstSegment ? (
61 | "Loading..."
62 | ) : (
63 | Start Learning
64 | )}
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/mobile-navigation.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetHeader,
6 | SheetTitle,
7 | SheetTrigger,
8 | } from "~/components/ui/sheet";
9 | import { Menu, Plus } from "lucide-react";
10 | import type { Module, Progress, Segment } from "~/db/schema";
11 | import { NavigationItems } from "./navigation-items";
12 | import { UserMenu } from "./user-menu";
13 | import { useState } from "react";
14 | import { useRouter } from "@tanstack/react-router";
15 | interface ModuleWithSegments extends Module {
16 | segments: Segment[];
17 | }
18 |
19 | interface MobileNavigationProps {
20 | modules: ModuleWithSegments[];
21 | currentSegmentId: number;
22 | isAdmin: boolean;
23 | isPremium: boolean;
24 | progress: Progress[];
25 | }
26 |
27 | export function MobileNavigation({
28 | modules,
29 | currentSegmentId,
30 | isAdmin,
31 | isPremium,
32 | progress,
33 | }: MobileNavigationProps) {
34 | const [open, setOpen] = useState(false);
35 | const router = useRouter();
36 |
37 | return (
38 |
39 |
40 |
41 | Quick Navigation
42 | Toggle Menu
43 |
44 |
45 |
46 |
47 | Course Content
48 |
49 |
50 | {/* Brand Header */}
51 |
52 |
53 |
58 |
59 | The 20 React Challenges Course
60 |
61 |
62 |
63 |
64 |
setOpen(false)}
71 | />
72 | {isAdmin && (
73 | {
78 | router.navigate({ to: "/learn/add" });
79 | }}
80 | >
81 |
82 | Add Segment
83 |
84 | )}
85 |
86 |
87 | {/* User menu at the bottom */}
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "~/lib/utils";
6 |
7 | // TODO: DO NOT REMOVE, THIS IS A STYLE I THOUGHT WAS PRETTY SLICK
8 | // module-card px-4 py-2 flex items-center gap-2 text-sm font-medium text-theme-700 dark:text-theme-300 hover:text-theme-800 dark:hover:text-theme-200 transition-all duration-200 hover:shadow-elevation-3
9 |
10 | const buttonVariants = cva(
11 | "cursor-pointer rounded-lg inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
12 | {
13 | variants: {
14 | variant: {
15 | default:
16 | "w-full justify-center module-card px-4 py-2 text-theme-700 dark:text-theme-300 hover:text-theme-800 dark:hover:text-theme-200 transition-all duration-200 hover:shadow-elevation-3",
17 | destructive:
18 | "border border-red-500 bg-transparent text-red-500 shadow-sm hover:bg-red-50 hover:text-red-600 dark:border-red-400 dark:text-red-400 dark:hover:bg-red-950/50 dark:hover:text-red-300",
19 | outline:
20 | "border border-theme-200 bg-background shadow-sm hover:bg-theme-100 hover:text-theme-700 dark:border-theme-800 dark:hover:bg-theme-950 dark:hover:text-theme-300",
21 | "gray-outline":
22 | "border border-gray-200 bg-transparent text-gray-500 shadow-sm hover:bg-gray-50 hover:text-gray-600 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-gray-950/50 dark:hover:text-gray-300",
23 | secondary:
24 | "bg-theme-100 text-theme-900 shadow-sm hover:bg-theme-200 dark:bg-theme-800 dark:text-theme-100 dark:hover:bg-theme-700",
25 | ghost:
26 | "hover:bg-theme-100 hover:text-theme-900 dark:hover:bg-theme-800 dark:hover:text-theme-100",
27 | link: "text-theme-500 underline-offset-4 hover:underline dark:text-theme-400",
28 | },
29 | size: {
30 | default: "h-9 px-4 py-2",
31 | sm: "h-8 px-3 text-xs",
32 | lg: "h-10 px-8",
33 | icon: "h-9 w-9",
34 | },
35 | },
36 | defaultVariants: { variant: "default", size: "default" },
37 | }
38 | );
39 |
40 | export interface ButtonProps
41 | extends React.ButtonHTMLAttributes,
42 | VariantProps {
43 | asChild?: boolean;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | ({ className, variant, size, asChild = false, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "button";
49 | return (
50 |
55 | );
56 | }
57 | );
58 | Button.displayName = "Button";
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/src/data-access/segments.ts:
--------------------------------------------------------------------------------
1 | import { database } from "~/db";
2 | import { segments, attachments, progress } from "~/db/schema";
3 | import { and, eq } from "drizzle-orm";
4 | import type { Progress, Segment, SegmentCreate } from "~/db/schema";
5 |
6 | export async function getSegments() {
7 | return database.query.segments.findMany();
8 | }
9 |
10 | export type SegmentWithProgress = Segment & { progress: Progress | null };
11 | export type GetSegmentsWithProgressResult = SegmentWithProgress[] | Segment[];
12 |
13 | export async function getSegmentsWithProgress(
14 | userId?: number
15 | ): Promise {
16 | if (!userId) {
17 | return getSegments();
18 | }
19 | const result = await database
20 | .select()
21 | .from(segments)
22 | .leftJoin(
23 | progress,
24 | and(eq(segments.id, progress.segmentId), eq(progress.userId, userId))
25 | )
26 | .orderBy(segments.order);
27 |
28 | return result.map((segment) => ({
29 | ...segment.segment,
30 | progress: segment.progress,
31 | }));
32 | }
33 |
34 | export async function getSegmentBySlug(slug: Segment["slug"]) {
35 | const result = await database
36 | .select()
37 | .from(segments)
38 | .where(eq(segments.slug, slug))
39 | .limit(1);
40 | return result[0];
41 | }
42 | export async function getSegmentById(segmentId: Segment["id"]) {
43 | const result = await database
44 | .select()
45 | .from(segments)
46 | .where(eq(segments.id, segmentId))
47 | .limit(1);
48 | return result[0];
49 | }
50 |
51 | export async function createSegment(segment: SegmentCreate) {
52 | const result = await database.insert(segments).values(segment).returning();
53 | return result[0];
54 | }
55 |
56 | export async function updateSegment(
57 | id: number,
58 | segment: Partial
59 | ) {
60 | const result = await database
61 | .update(segments)
62 | .set(segment)
63 | .where(eq(segments.id, id))
64 | .returning();
65 | return result[0];
66 | }
67 |
68 | export async function deleteSegment(id: number) {
69 | const result = await database
70 | .delete(segments)
71 | .where(eq(segments.id, id))
72 | .returning();
73 | return result[0];
74 | }
75 |
76 | export async function getSegmentAttachments(segmentId: Segment["id"]) {
77 | return database
78 | .select()
79 | .from(attachments)
80 | .where(eq(attachments.segmentId, segmentId));
81 | }
82 |
83 | export async function createAttachment(attachment: {
84 | segmentId: Segment["id"];
85 | fileName: string;
86 | fileKey: string;
87 | }) {
88 | const result = await database
89 | .insert(attachments)
90 | .values(attachment)
91 | .returning();
92 | return result[0];
93 | }
94 |
95 | export async function deleteAttachment(id: number) {
96 | const result = await database
97 | .delete(attachments)
98 | .where(eq(attachments.id, id))
99 | .returning();
100 | return result[0];
101 | }
102 |
--------------------------------------------------------------------------------
/src/utils/storage/r2.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | IStorage,
3 | StreamFileRange,
4 | StreamFileResponse,
5 | } from "./storage.interface";
6 |
7 | import type { Readable } from "node:stream";
8 |
9 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
10 | import {
11 | DeleteObjectCommand,
12 | GetObjectCommand,
13 | HeadObjectCommand,
14 | PutObjectCommand,
15 | S3Client,
16 | } from "@aws-sdk/client-s3";
17 |
18 | export class R2Storage implements IStorage {
19 | private readonly client: S3Client;
20 | private readonly bucket: string;
21 |
22 | constructor() {
23 | const endpoint = process.env.R2_ENDPOINT;
24 | const accessKeyId = process.env.R2_ACCESS_KEY_ID;
25 | const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
26 | const bucket = process.env.R2_BUCKET;
27 |
28 | if (!endpoint || !accessKeyId || !secretAccessKey || !bucket) {
29 | throw new Error(
30 | "R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_BUCKET must be set"
31 | );
32 | }
33 |
34 | this.bucket = bucket;
35 | this.client = new S3Client({
36 | region: "auto",
37 | endpoint,
38 | credentials: {
39 | accessKeyId,
40 | secretAccessKey,
41 | },
42 | });
43 | }
44 |
45 | async upload(key: string, data: Buffer) {
46 | const command = new PutObjectCommand({
47 | Bucket: this.bucket,
48 | Key: key,
49 | Body: data,
50 | ContentType: "video/mp4",
51 | });
52 |
53 | await this.client.send(command);
54 | }
55 |
56 | async delete(key: string) {
57 | await this.client.send(
58 | new DeleteObjectCommand({
59 | Bucket: this.bucket,
60 | Key: key,
61 | })
62 | );
63 | }
64 |
65 | async exists(key: string): Promise {
66 | try {
67 | await this.client.send(
68 | new HeadObjectCommand({
69 | Bucket: this.bucket,
70 | Key: key,
71 | })
72 | );
73 | return true;
74 | } catch (error: any) {
75 | if (
76 | error.name === "NotFound" ||
77 | error.$metadata?.httpStatusCode === 404
78 | ) {
79 | return false;
80 | }
81 | throw error;
82 | }
83 | }
84 |
85 | async getStream(
86 | _key: string,
87 | _rangeHeader: string | null
88 | ): Promise {
89 | throw new Error(
90 | "getStream is not supported for R2. Use getPresignedUrl instead."
91 | );
92 | }
93 |
94 | async getPresignedUrl(key: string) {
95 | return await getSignedUrl(
96 | this.client,
97 | new GetObjectCommand({
98 | Bucket: this.bucket,
99 | Key: key,
100 | }),
101 | { expiresIn: 60 * 60 } // 1 hour
102 | );
103 | }
104 |
105 | async getPresignedUploadUrl(key: string) {
106 | return await getSignedUrl(
107 | this.client,
108 | new PutObjectCommand({
109 | Bucket: this.bucket,
110 | Key: key,
111 | ContentType: "video/mp4",
112 | }),
113 | { expiresIn: 60 * 60 } // 1 hour
114 | );
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { useSuspenseQuery } from "@tanstack/react-query";
2 | import { createServerFn } from "@tanstack/react-start";
3 | import { createContext, useContext, useEffect, useState } from "react";
4 | import { z } from "zod";
5 | import { getCookie, setCookie } from "@tanstack/react-start/server";
6 |
7 | type Theme = "dark" | "light" | "system";
8 |
9 | type ThemeProviderProps = {
10 | children: React.ReactNode;
11 | defaultTheme?: Theme;
12 | storageKey?: string;
13 | };
14 |
15 | type ThemeProviderState = { theme: Theme; setTheme: (theme: Theme) => void };
16 |
17 | const THEME_COOKIE_NAME = "ui-theme";
18 |
19 | const initialState: ThemeProviderState = {
20 | theme: "system",
21 | setTheme: () => null,
22 | };
23 |
24 | const ThemeProviderContext = createContext(initialState);
25 |
26 | export const getThemeFn = createServerFn().handler(async () => {
27 | const theme = getCookie(THEME_COOKIE_NAME);
28 | return theme ?? "system";
29 | });
30 |
31 | export const setThemeFn = createServerFn({ method: "POST" })
32 | .validator(z.object({ theme: z.enum(["dark", "light", "system"]) }))
33 | .handler(async ({ data }) => {
34 | setCookie(THEME_COOKIE_NAME, data.theme);
35 | return data.theme;
36 | });
37 |
38 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
39 | const themeQuery = useSuspenseQuery({
40 | queryKey: ["theme"],
41 | queryFn: () => getThemeFn(),
42 | });
43 |
44 | useEffect(() => {
45 | window
46 | .matchMedia("(prefers-color-scheme: dark)")
47 | .addEventListener("change", event => {
48 | if (themeQuery.data === "system") {
49 | const newColorScheme = event.matches ? "dark" : "light";
50 | const root = window.document.documentElement;
51 | root.classList.remove("light", "dark");
52 | root.classList.add(newColorScheme);
53 | }
54 | });
55 | }, [themeQuery.data]);
56 |
57 | useEffect(() => {
58 | const theme = themeQuery.data as Theme;
59 | const root = window.document.documentElement;
60 |
61 | root.classList.remove("light", "dark");
62 |
63 | if (theme === "system") {
64 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
65 | .matches
66 | ? "dark"
67 | : "light";
68 |
69 | root.classList.add(systemTheme);
70 | return;
71 | }
72 |
73 | root.classList.add(theme);
74 | }, [themeQuery.data]);
75 |
76 | const value = {
77 | theme: themeQuery.data as Theme,
78 | setTheme: (theme: Theme) => {
79 | setThemeFn({ data: { theme } }).then(() => {
80 | themeQuery.refetch();
81 | });
82 | },
83 | };
84 |
85 | return (
86 |
87 | {children}
88 |
89 | );
90 | }
91 |
92 | export const useTheme = () => {
93 | const context = useContext(ThemeProviderContext);
94 |
95 | if (context === undefined)
96 | throw new Error("useTheme must be used within a ThemeProvider");
97 |
98 | return context;
99 | };
100 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/edit-segment/edit-segment-header.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from "@tanstack/react-router";
2 | import { Button } from "~/components/ui/button";
3 | import { ChevronLeft, Edit, Sparkles } from "lucide-react";
4 |
5 | export function EditSegmentHeader() {
6 | const router = useRouter();
7 |
8 | return (
9 |
10 | {/* Subtle gradient overlay */}
11 |
12 |
13 |
14 |
15 |
router.history.back()}
18 | className="text-theme-600 dark:text-theme-400"
19 | >
20 |
21 | Back to Course
22 |
23 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 | Edit Content
37 |
38 |
39 |
40 |
41 | Update and enhance your learning segment
42 |
43 |
44 |
45 |
46 |
47 | {/* Decorative elements */}
48 |
59 |
60 |
61 | {/* Bottom border with gradient */}
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/src/routes/-components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@tanstack/react-router";
2 | import { useFirstSegment } from "~/hooks/use-first-segment";
3 |
4 | export function FooterSection() {
5 | const firstSegment = useFirstSegment();
6 |
7 | return (
8 |
9 |
10 |
11 |
12 |
13 | Learn
14 |
15 |
16 |
17 | {firstSegment.data && (
18 |
23 | Get Started
24 |
25 | )}
26 |
27 |
28 |
29 |
30 |
31 |
32 | Purchase
33 |
34 |
44 |
45 |
46 |
47 |
48 | Legal
49 |
50 |
51 |
52 |
56 | Terms of Service
57 |
58 |
59 |
60 |
64 | Privacy Policy
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 | Contact
73 |
74 |
84 |
85 |
86 |
87 |
88 |
© 2025 Seibert Software Solutions, LLC. All rights reserved.
89 |
90 |
91 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tanstack-start-example-basic-react-query",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "dev": "npm run docker:up && vite dev",
8 | "build": "vite build",
9 | "start": "npm run db:migrate && node .output/server/index.mjs",
10 | "docker:up": "docker compose up -d",
11 | "docker:down": "docker compose down",
12 | "db:push": "drizzle-kit push --config=drizzle.config.ts",
13 | "db:migrate": "tsx ./src/db/migrate.ts",
14 | "db:generate": "drizzle-kit generate --config=drizzle.config.ts",
15 | "db:studio": "drizzle-kit studio",
16 | "db:clear": "tsx ./src/db/clear.ts",
17 | "db:seed": "tsx ./src/db/seed.ts",
18 | "db:reset": "npm run db:clear && npm run db:migrate && npm run db:seed",
19 | "stripe:webhook": "stripe listen --forward-to http://localhost:3000/api/stripe/webhook",
20 | "upload:videos": "tsx ./scripts/upload-videos-to-r2.ts"
21 | },
22 | "dependencies": {
23 | "@aws-sdk/client-s3": "^3.850.0",
24 | "@aws-sdk/lib-storage": "^3.850.0",
25 | "@aws-sdk/s3-request-presigner": "^3.850.0",
26 | "@hello-pangea/dnd": "^18.0.1",
27 | "@hookform/resolvers": "^5.2.0",
28 | "@radix-ui/react-alert-dialog": "^1.1.14",
29 | "@radix-ui/react-checkbox": "^1.3.2",
30 | "@radix-ui/react-dialog": "^1.1.14",
31 | "@radix-ui/react-dropdown-menu": "^2.1.15",
32 | "@radix-ui/react-label": "^2.1.7",
33 | "@radix-ui/react-popover": "^1.1.14",
34 | "@radix-ui/react-progress": "^1.1.7",
35 | "@radix-ui/react-separator": "^1.1.7",
36 | "@radix-ui/react-slot": "^1.2.3",
37 | "@radix-ui/react-switch": "^1.2.5",
38 | "@radix-ui/react-toast": "^1.2.14",
39 | "@radix-ui/react-tooltip": "^1.2.7",
40 | "@stripe/stripe-js": "^7.6.1",
41 | "@tailwindcss/vite": "^4.1.11",
42 | "@tanstack/react-query": "^5.83.0",
43 | "@tanstack/react-query-devtools": "^5.83.0",
44 | "@tanstack/react-router": "^1.130.2",
45 | "@tanstack/react-router-with-query": "^1.130.2",
46 | "@tanstack/react-start": "^1.130.2",
47 | "arctic": "^3.7.0",
48 | "class-variance-authority": "^0.7.1",
49 | "clsx": "^2.1.1",
50 | "cmdk": "^1.1.1",
51 | "drizzle-orm": "^0.44.3",
52 | "entities": "^6.0.1",
53 | "framer-motion": "^12.23.10",
54 | "js-cookie": "^3.0.5",
55 | "lucide-react": "^0.528.0",
56 | "nprogress": "^0.2.0",
57 | "pg": "^8.16.3",
58 | "react": "^19.1.0",
59 | "react-confetti": "^6.4.0",
60 | "react-dom": "^19.1.0",
61 | "react-dropzone": "^14.3.8",
62 | "react-google-recaptcha": "^3.1.0",
63 | "react-hook-form": "^7.61.1",
64 | "react-markdown": "^10.1.0",
65 | "react-use": "^17.6.0",
66 | "redaxios": "^0.5.1",
67 | "sonner": "^2.0.6",
68 | "stripe": "^18.3.0",
69 | "tailwind-merge": "^3.3.1",
70 | "tw-animate-css": "^1.3.6",
71 | "uuid": "^11.1.0",
72 | "vite": "^7.0.6",
73 | "zod": "^4.0.10"
74 | },
75 | "devDependencies": {
76 | "@tailwindcss/typography": "^0.5.16",
77 | "@tanstack/react-router-devtools": "^1.130.2",
78 | "@types/js-cookie": "^3.0.6",
79 | "@types/node": "^24.1.0",
80 | "@types/nprogress": "^0.2.3",
81 | "@types/pg": "^8.15.4",
82 | "@types/react": "^19.1.8",
83 | "@types/react-dom": "^19.1.6",
84 | "@types/react-google-recaptcha": "^2.1.9",
85 | "drizzle-kit": "^0.31.4",
86 | "tailwindcss": "^4.1.11",
87 | "typescript": "^5.8.3",
88 | "vite-tsconfig-paths": "^5.1.4"
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/use-cases/segments.ts:
--------------------------------------------------------------------------------
1 | import {
2 | createSegment,
3 | deleteSegment,
4 | getSegmentById,
5 | getSegments,
6 | updateSegment,
7 | getSegmentAttachments,
8 | deleteAttachment,
9 | getSegmentBySlug,
10 | } from "~/data-access/segments";
11 | import type { Segment, SegmentCreate } from "~/db/schema";
12 | import { eq } from "drizzle-orm";
13 | import { getOrCreateModuleUseCase } from "./modules";
14 | import { database } from "~/db";
15 | import { segments } from "~/db/schema";
16 | import { getStorage } from "~/utils/storage";
17 |
18 | export async function getSegmentsUseCase() {
19 | return getSegments();
20 | }
21 |
22 | export async function getSegmentBySlugUseCase(slug: Segment["slug"]) {
23 | return getSegmentBySlug(slug);
24 | }
25 |
26 | export async function getSegmentByIdUseCase(id: Segment["id"]) {
27 | return getSegmentById(id);
28 | }
29 |
30 | export async function addSegmentUseCase(
31 | segment: SegmentCreate & { moduleTitle: string }
32 | ) {
33 | // Get or create the module
34 | const module = await getOrCreateModuleUseCase(segment.moduleTitle);
35 |
36 | // Create the segment with the module's ID
37 | return createSegment({ ...segment, moduleId: module.id });
38 | }
39 |
40 | export async function editSegmentUseCase(
41 | id: number,
42 | segment: Partial
43 | ) {
44 | return updateSegment(id, segment);
45 | }
46 |
47 | export async function removeSegmentUseCase(id: number) {
48 | return deleteSegment(id);
49 | }
50 |
51 | export type SegmentUpdate = Partial<
52 | Omit
53 | >;
54 |
55 | export async function updateSegmentUseCase(
56 | segmentId: number,
57 | data: SegmentUpdate & { moduleTitle: string }
58 | ) {
59 | const { storage } = getStorage();
60 | const segment = await getSegmentById(segmentId);
61 | if (!segment) throw new Error("Segment not found");
62 |
63 | // Handle video deletion if updating video
64 | if (segment.videoKey && data.videoKey) {
65 | await storage.delete(segment.videoKey);
66 | }
67 |
68 | // Get or create the module
69 | const module = await getOrCreateModuleUseCase(data.moduleTitle);
70 |
71 | // Update the segment with the module's ID
72 | return await updateSegment(segmentId, { ...data, moduleId: module.id });
73 | }
74 |
75 | export async function deleteSegmentUseCase(segmentId: number) {
76 | const { storage } = getStorage();
77 | const segment = await getSegmentById(segmentId);
78 | if (!segment) throw new Error("Segment not found");
79 |
80 | // Delete video file if it exists
81 | if (segment.videoKey) {
82 | await storage.delete(segment.videoKey);
83 | }
84 |
85 | // Get and delete all attachment files
86 | const attachments = await getSegmentAttachments(segmentId);
87 | await Promise.all(
88 | attachments.map(async attachment => {
89 | await storage.delete(attachment.fileKey);
90 | // await deleteAttachment(attachment.id);
91 | })
92 | );
93 |
94 | // Finally delete the segment (this will cascade delete attachments due to foreign key)
95 | return deleteSegment(segmentId);
96 | }
97 |
98 | export async function reorderSegmentsUseCase(
99 | updates: { id: number; order: number }[]
100 | ) {
101 | return database.transaction(async tx => {
102 | const results = [];
103 | for (const update of updates) {
104 | const [result] = await tx
105 | .update(segments)
106 | .set({ order: update.order, updatedAt: new Date() })
107 | .where(eq(segments.id, update.id))
108 | .returning();
109 | results.push(result);
110 | }
111 | return results;
112 | });
113 | }
114 |
--------------------------------------------------------------------------------
/src/db/seed.ts:
--------------------------------------------------------------------------------
1 | import "dotenv/config";
2 | import { database } from "./index";
3 | import { modules, segments } from "./schema";
4 | import type { ModuleCreate } from "./schema";
5 |
6 | async function main() {
7 | const moduleData = [
8 | {
9 | title: "Getting Started",
10 | order: 1,
11 | segments: [
12 | { title: "Welcome to the Course", length: "5:30", isPremium: false },
13 | {
14 | title: "Setting Up Your Environment",
15 | length: "10:45",
16 | isPremium: false,
17 | },
18 | { title: "Course Overview", length: "7:20", isPremium: false },
19 | ],
20 | },
21 | {
22 | title: "React Fundamentals",
23 | order: 2,
24 | segments: [
25 | {
26 | title: "Introduction to React and Component Basics",
27 | length: "8:30",
28 | isPremium: true,
29 | },
30 | {
31 | title: "Understanding JSX and Props",
32 | length: "12:45",
33 | isPremium: true,
34 | },
35 | {
36 | title: "State Management with useState",
37 | length: "15:20",
38 | isPremium: true,
39 | },
40 | ],
41 | },
42 | {
43 | title: "React Hooks Deep Dive",
44 | order: 3,
45 | segments: [
46 | {
47 | title: "useEffect for Side Effects",
48 | length: "14:30",
49 | isPremium: true,
50 | },
51 | { title: "Custom Hooks Development", length: "18:15", isPremium: true },
52 | {
53 | title: "useContext for State Management",
54 | length: "16:40",
55 | isPremium: true,
56 | },
57 | ],
58 | },
59 | {
60 | title: "Advanced React Patterns",
61 | order: 4,
62 | segments: [
63 | {
64 | title: "Component Composition Patterns",
65 | length: "13:20",
66 | isPremium: true,
67 | },
68 | {
69 | title: "Performance Optimization with useMemo",
70 | length: "15:45",
71 | isPremium: true,
72 | },
73 | {
74 | title: "Building a Custom Hook Library",
75 | length: "20:10",
76 | isPremium: true,
77 | },
78 | ],
79 | },
80 | ];
81 |
82 | // First, create the modules and store their IDs
83 | const createdModules = [];
84 | for (const module of moduleData) {
85 | const [createdModule] = await database
86 | .insert(modules)
87 | .values(module)
88 | .returning();
89 | createdModules.push(createdModule);
90 | }
91 |
92 | // Then create all segments with proper references to their modules
93 | for (let i = 0; i < createdModules.length; i++) {
94 | const module = createdModules[i];
95 | const segments_data = moduleData[i].segments;
96 |
97 | for (const [segmentIndex, segment] of segments_data.entries()) {
98 | await database
99 | .insert(segments)
100 | .values({
101 | slug: segment.title.toLowerCase().replace(/\s+/g, "-"),
102 | title: segment.title,
103 | content: `Learn about ${segment.title.toLowerCase()} in this comprehensive lesson.`,
104 | order: segmentIndex + 1,
105 | length: segment.length,
106 | isPremium: segment.isPremium,
107 | moduleId: module.id,
108 | videoKey: `${module.title.toLowerCase().replace(/\s+/g, "-")}-video-${segmentIndex + 1}`,
109 | });
110 | }
111 | }
112 | }
113 |
114 | async function seed() {
115 | console.log("Database seeded!");
116 | }
117 |
118 | main().catch((e) => {
119 | console.error(e);
120 | process.exit(1);
121 | });
122 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/video-player.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { createServerFn, useServerFn } from "@tanstack/react-start";
3 | import { z } from "zod";
4 | import { AuthenticationError } from "~/use-cases/errors";
5 | import { getSegmentByIdUseCase } from "~/use-cases/segments";
6 | import { getAuthenticatedUser } from "~/utils/auth";
7 | import { getStorage } from "~/utils/storage";
8 | import { Play, Loader2 } from "lucide-react";
9 |
10 | interface VideoPlayerProps {
11 | segmentId: number;
12 | }
13 |
14 | export function VideoPlayer({ segmentId }: VideoPlayerProps) {
15 | const getVideoUrl = useServerFn(getVideoUrlFn);
16 | const { data, isLoading, error } = useQuery({
17 | queryKey: ["video-url", segmentId],
18 | queryFn: () => getVideoUrl({ data: { segmentId } }),
19 | refetchOnWindowFocus: false,
20 | retry: false,
21 | staleTime: 1000 * 60 * 55, // 55 minutes
22 | gcTime: 1000 * 60 * 60, // 1 hour
23 | });
24 |
25 | if (isLoading) {
26 | return (
27 |
28 |
29 |
35 |
Loading video...
36 |
37 |
38 | );
39 | }
40 |
41 | if (error) {
42 | return (
43 |
44 |
45 |
48 |
49 |
Unable to load video
50 |
51 | Please try refreshing the page
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | if (data) {
60 | return (
61 |
68 | Your browser does not support the video tag.
69 |
70 | );
71 | }
72 |
73 | return null;
74 | }
75 |
76 | export const getVideoUrlFn = createServerFn({ method: "GET" })
77 | .validator(z.object({ segmentId: z.number() }))
78 | .handler(async ({ data }) => {
79 | const { storage, type } = getStorage();
80 |
81 | if (type !== "r2") {
82 | return { videoUrl: `/api/segments/${data.segmentId}/video` };
83 | }
84 |
85 | const user = await getAuthenticatedUser();
86 |
87 | const segment = await getSegmentByIdUseCase(data.segmentId);
88 | if (!segment) throw new Error("Segment not found");
89 | if (!segment.videoKey) throw new Error("Video not attached to segment");
90 |
91 | if (segment.isPremium) {
92 | if (!user) throw new AuthenticationError();
93 | if (!user.isPremium && !user.isAdmin) {
94 | throw new Error("You don't have permission to access this video");
95 | }
96 | }
97 |
98 | const url = await storage.getPresignedUrl(segment.videoKey);
99 | return { videoUrl: url };
100 | });
101 |
--------------------------------------------------------------------------------
/src/routes/-components/newsletter.tsx:
--------------------------------------------------------------------------------
1 | import { createServerFn } from "@tanstack/react-start";
2 | import { z } from "zod";
3 | import { env } from "~/utils/env";
4 | import { Button } from "~/components/ui/button";
5 | import { useNewsletterSubscription } from "~/hooks/use-newsletter-subscription";
6 |
7 | declare global {
8 | interface Window {
9 | grecaptcha: any;
10 | }
11 | }
12 |
13 | export const subscribeFn = createServerFn()
14 | .validator(
15 | z.object({ email: z.string().email(), recaptchaToken: z.string() })
16 | )
17 | .handler(async ({ data }) => {
18 | const response = await fetch(
19 | "https://www.google.com/recaptcha/api/siteverify",
20 | {
21 | method: "POST",
22 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
23 | body: `secret=${env.RECAPTCHA_SECRET_KEY}&response=${data.recaptchaToken}`,
24 | }
25 | );
26 | const json = (await response.json()) as {
27 | success: boolean;
28 | score: number;
29 | };
30 |
31 | if (!json.success) {
32 | throw new Error("invalid recaptcha token");
33 | }
34 |
35 | if (json.score < 0.5) {
36 | throw new Error("recaptcha score too low");
37 | }
38 |
39 | const params = new URLSearchParams();
40 | params.append("email", data.email);
41 | await fetch(env.MAILING_LIST_ENDPOINT, {
42 | method: "POST",
43 | headers: {
44 | Authorization: `Bearer ${env.MAILING_LIST_PASSWORD}`,
45 | "Content-Type": "application/x-www-form-urlencoded",
46 | },
47 | body: params.toString(),
48 | });
49 | });
50 |
51 | export function NewsletterSection() {
52 | const { email, setEmail, isSubmitted, isLoading, handleSubmit } =
53 | useNewsletterSubscription();
54 |
55 | return (
56 |
57 |
58 |
59 |
60 | /subscribe
61 |
62 |
63 | Join our community to receive early access to new content and
64 | special discounts reserved for subscribers.
65 |
66 |
67 | {isSubmitted ? (
68 |
69 |
70 |
71 | Thank you for subscribing!
72 |
73 |
74 | We'll be in touch soon with updates and exclusive content.
75 |
76 |
77 |
78 | ) : (
79 |
94 | )}
95 |
96 |
97 |
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/delete-module-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button, buttonVariants } from "~/components/ui/button";
2 | import { Trash2 } from "lucide-react";
3 | import { createServerFn } from "@tanstack/react-start";
4 | import { z } from "zod";
5 | import {
6 | AlertDialog,
7 | AlertDialogAction,
8 | AlertDialogCancel,
9 | AlertDialogContent,
10 | AlertDialogDescription,
11 | AlertDialogFooter,
12 | AlertDialogHeader,
13 | AlertDialogTitle,
14 | AlertDialogTrigger,
15 | } from "~/components/ui/alert-dialog";
16 | import { adminMiddleware } from "~/lib/auth";
17 | import { deleteModuleUseCase } from "~/use-cases/modules";
18 | import { useToast } from "~/hooks/use-toast";
19 | import { useRouter } from "@tanstack/react-router";
20 |
21 | export const deleteModuleFn = createServerFn()
22 | .middleware([adminMiddleware])
23 | .validator(z.object({ moduleId: z.coerce.number() }))
24 | .handler(async ({ data }) => {
25 | await deleteModuleUseCase(data.moduleId);
26 | });
27 |
28 | interface DeleteModuleButtonProps {
29 | moduleId: number;
30 | moduleTitle: string;
31 | }
32 |
33 | export function DeleteModuleButton({
34 | moduleId,
35 | moduleTitle,
36 | }: DeleteModuleButtonProps) {
37 | const { toast } = useToast();
38 | const router = useRouter();
39 |
40 | const handleDeleteModule = async () => {
41 | try {
42 | await deleteModuleFn({ data: { moduleId } });
43 |
44 | toast({
45 | title: "Module deleted successfully!",
46 | description: `"${moduleTitle}" has been permanently deleted.`,
47 | });
48 |
49 | // Refresh the page to update the module list
50 | router.invalidate();
51 | } catch (error) {
52 | toast({
53 | title: "Failed to delete module",
54 | description: "Please try again.",
55 | variant: "destructive",
56 | });
57 | }
58 | };
59 |
60 | return (
61 |
62 |
63 |
68 |
69 |
70 |
71 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | Delete Module
82 |
83 |
84 |
85 | Are you sure you want to delete the module{" "}
86 | "{moduleTitle}" ? This action cannot be undone and
87 | will permanently delete the module and all its associated segments.
88 |
89 |
90 |
91 |
94 | Cancel
95 |
96 |
100 | Delete Module
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/src/routes/learn/-components/new-module-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "~/components/ui/button";
2 | import { Plus } from "lucide-react";
3 | import { useState } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogFooter,
10 | } from "~/components/ui/dialog";
11 | import { Input } from "~/components/ui/input";
12 | import { createServerFn } from "@tanstack/react-start";
13 | import { z } from "zod";
14 | import { adminMiddleware } from "~/lib/auth";
15 | import { getOrCreateModuleUseCase } from "~/use-cases/modules";
16 | import { useRouter } from "@tanstack/react-router";
17 | import { useQueryClient } from "@tanstack/react-query";
18 |
19 | const createModuleFn = createServerFn()
20 | .middleware([adminMiddleware])
21 | .validator(
22 | z.object({
23 | title: z
24 | .string()
25 | .min(1, "Module title is required")
26 | .max(100, "Module title must be less than 100 characters"),
27 | })
28 | )
29 | .handler(async ({ data }) => {
30 | const module = await getOrCreateModuleUseCase(data.title);
31 | return module;
32 | });
33 |
34 | export function NewModuleButton() {
35 | const [open, setOpen] = useState(false);
36 | const [title, setTitle] = useState("");
37 | const [isLoading, setIsLoading] = useState(false);
38 | const router = useRouter();
39 | const queryClient = useQueryClient();
40 |
41 | const handleSubmit = async (e: React.FormEvent) => {
42 | e.preventDefault();
43 |
44 | if (!title.trim()) return;
45 |
46 | setIsLoading(true);
47 | try {
48 | await createModuleFn({ data: { title: title.trim() } });
49 | queryClient.invalidateQueries({ queryKey: ["modules"] });
50 | setTitle("");
51 | setOpen(false);
52 | // Refresh the page to show the new module
53 | router.invalidate();
54 | } catch (error) {
55 | console.error("Failed to create module:", error);
56 | } finally {
57 | setIsLoading(false);
58 | }
59 | };
60 |
61 | return (
62 | <>
63 | setOpen(true)}
66 | >
67 |
68 | New Module
69 |
70 |
71 |
72 |
73 |
74 | Create New Module
75 |
76 |
107 |
108 |
109 | >
110 | );
111 | }
112 |
--------------------------------------------------------------------------------