├── .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 |
10 | Login with Google 11 |
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 | 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 |
8 |
{children}
9 |
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 | 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 | 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 |