From f936943c60a91df4d7d20617bafb7ef0f6972b7d Mon Sep 17 00:00:00 2001 From: gladyshav Date: Sat, 2 May 2026 09:51:47 +0300 Subject: [PATCH 1/4] Add course star-rating system Enrolled students can leave a 1-5 star rating per course (one rating per user, updatable). Average + count appear on the course detail hero, catalog cards, and home featured cards. Dashboard intentionally unchanged. Includes drizzle migration, ratingService with tests, and shared StarRatingDisplay/StarRatingInput components. Co-Authored-By: Claude Opus 4.7 --- app/components/star-rating.tsx | 109 +++ app/db/schema.ts | 34 +- app/routes/courses.$slug.tsx | 88 +- app/routes/courses.tsx | 14 + app/routes/home.tsx | 26 +- app/services/ratingService.test.ts | 187 ++++ app/services/ratingService.ts | 82 ++ drizzle/0003_thankful_nitro.sql | 12 + drizzle/meta/0003_snapshot.json | 1341 ++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + 10 files changed, 1893 insertions(+), 7 deletions(-) create mode 100644 app/components/star-rating.tsx create mode 100644 app/services/ratingService.test.ts create mode 100644 app/services/ratingService.ts create mode 100644 drizzle/0003_thankful_nitro.sql create mode 100644 drizzle/meta/0003_snapshot.json diff --git a/app/components/star-rating.tsx b/app/components/star-rating.tsx new file mode 100644 index 00000000..e0a7ff95 --- /dev/null +++ b/app/components/star-rating.tsx @@ -0,0 +1,109 @@ +import { Star } from "lucide-react"; +import { cn } from "~/lib/utils"; + +const SIZES = { + sm: "size-3.5", + md: "size-4", + lg: "size-5", +} as const; + +type Size = keyof typeof SIZES; + +export function StarRatingDisplay({ + average, + count, + size = "sm", + className, +}: { + average: number | null; + count: number; + size?: Size; + className?: string; +}) { + const filled = count > 0 && average !== null ? Math.round(average) : 0; + const sizeClass = SIZES[size]; + + return ( + + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {count === 0 ? ( + No ratings yet + ) : ( + + + {average!.toFixed(1)} + {" "} + ({count}) + + )} + + ); +} + +export function StarRatingInput({ + name = "rating", + value, + size = "md", + disabled = false, +}: { + name?: string; + value: number | null; + size?: Size; + disabled?: boolean; +}) { + const sizeClass = SIZES[size]; + + return ( + + {[1, 2, 3, 4, 5].map((n) => { + const filled = value !== null && n <= value; + return ( + + ); + })} + + ); +} diff --git a/app/db/schema.ts b/app/db/schema.ts index 69a8ad02..3d76c3db 100644 --- a/app/db/schema.ts +++ b/app/db/schema.ts @@ -1,4 +1,10 @@ -import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core"; +import { + sqliteTable, + text, + integer, + real, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; export enum UserRole { Student = "student", @@ -116,6 +122,32 @@ export const enrollments = sqliteTable("enrollments", { completedAt: text("completed_at"), }); +export const ratings = sqliteTable( + "ratings", + { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: integer("user_id") + .notNull() + .references(() => users.id), + courseId: integer("course_id") + .notNull() + .references(() => courses.id), + rating: integer("rating").notNull(), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updated_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + }, + (table) => ({ + userCourseUnique: uniqueIndex("ratings_user_course_unique").on( + table.userId, + table.courseId + ), + }) +); + export const lessonProgress = sqliteTable("lesson_progress", { id: integer("id").primaryKey({ autoIncrement: true }), userId: integer("user_id") diff --git a/app/routes/courses.$slug.tsx b/app/routes/courses.$slug.tsx index 363cb359..75327b99 100644 --- a/app/routes/courses.$slug.tsx +++ b/app/routes/courses.$slug.tsx @@ -1,6 +1,7 @@ import { useEffect } from "react"; -import { Link, useSearchParams } from "react-router"; +import { Link, useFetcher, useSearchParams } from "react-router"; import { toast } from "sonner"; +import { z } from "zod"; import type { Route } from "./+types/courses.$slug"; import { getCourseBySlug, @@ -13,8 +14,14 @@ import { getLessonProgressForCourse, getNextIncompleteLesson, } from "~/services/progressService"; +import { + getCourseRatingStats, + getUserRating, + upsertRating, +} from "~/services/ratingService"; import { getCurrentUserId } from "~/lib/session"; import { LessonProgressStatus } from "~/db/schema"; +import { parseFormData } from "~/lib/validation"; import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Button } from "~/components/ui/button"; import { Skeleton } from "~/components/ui/skeleton"; @@ -37,6 +44,10 @@ import { } from "lucide-react"; import { CourseImage } from "~/components/course-image"; import { UserAvatar } from "~/components/user-avatar"; +import { + StarRatingDisplay, + StarRatingInput, +} from "~/components/star-rating"; import { data, isRouteErrorResponse } from "react-router"; import { formatDuration, formatPrice } from "~/lib/utils"; import { renderMarkdown } from "~/lib/markdown.server"; @@ -71,6 +82,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { let progress = 0; let lessonProgressMap: Record = {}; let nextLessonId: number | null = null; + let userRating: number | null = null; if (currentUserId) { enrolled = isUserEnrolled(currentUserId, course.id); @@ -88,9 +100,13 @@ export async function loader({ params, request }: Route.LoaderArgs) { const nextLesson = getNextIncompleteLesson(currentUserId, course.id); nextLessonId = nextLesson?.id ?? null; + + userRating = getUserRating(currentUserId, course.id)?.rating ?? null; } } + const ratingStats = getCourseRatingStats(course.id); + // Render sales copy from Markdown to HTML server-side const salesCopyHtml = courseWithDetails.salesCopy ? await renderMarkdown(courseWithDetails.salesCopy) @@ -113,10 +129,42 @@ export async function loader({ params, request }: Route.LoaderArgs) { currentUserId, pppPrice, tierInfo, + ratingStats, + userRating, }; } -// No action — enrollment is handled via the purchase confirmation page +const rateActionSchema = z.object({ + intent: z.literal("rate"), + rating: z.coerce.number().int().min(1).max(5), +}); + +export async function action({ params, request }: Route.ActionArgs) { + const slug = params.slug; + const course = getCourseBySlug(slug); + + if (!course) { + throw data("Course not found", { status: 404 }); + } + + const currentUserId = await getCurrentUserId(request); + if (!currentUserId) { + throw data("You must be logged in to rate a course.", { status: 401 }); + } + + const formData = await request.formData(); + const parsed = parseFormData(formData, rateActionSchema); + if (!parsed.success) { + throw data("Invalid rating submission.", { status: 400 }); + } + + if (!isUserEnrolled(currentUserId, course.id)) { + throw data("Only enrolled students can rate this course.", { status: 403 }); + } + + upsertRating(currentUserId, course.id, parsed.data.rating); + return { ok: true }; +} export function HydrateFallback() { return ( @@ -181,9 +229,24 @@ export default function CourseDetail({ loaderData }: Route.ComponentProps) { currentUserId, pppPrice, tierInfo, + ratingStats, + userRating, } = loaderData; const isInstructor = currentUserId === course.instructorId; const [searchParams, setSearchParams] = useSearchParams(); + const ratingFetcher = useFetcher(); + const isSubmittingRating = ratingFetcher.state !== "idle"; + + useEffect(() => { + if ( + ratingFetcher.state === "idle" && + ratingFetcher.data && + "ok" in ratingFetcher.data && + ratingFetcher.data.ok + ) { + toast.success("Thanks for rating!"); + } + }, [ratingFetcher.state, ratingFetcher.data]); useEffect(() => { if (searchParams.get("already_enrolled") === "1") { @@ -320,6 +383,11 @@ export default function CourseDetail({ loaderData }: Route.ComponentProps) { {formatDuration(totalDuration, true, false, false)} total )} + @@ -413,6 +481,22 @@ export default function CourseDetail({ loaderData }: Route.ComponentProps) { Buy More Seats +
+

+ {userRating !== null + ? "Your rating" + : "Rate this course"} +

+ + + + +
) : ( enrollButton diff --git a/app/routes/courses.tsx b/app/routes/courses.tsx index 47c5be5d..9a134312 100644 --- a/app/routes/courses.tsx +++ b/app/routes/courses.tsx @@ -9,10 +9,12 @@ import { Skeleton } from "~/components/ui/skeleton"; import { AlertTriangle, BookOpen, Search } from "lucide-react"; import { CourseImage } from "~/components/course-image"; import { UserAvatar } from "~/components/user-avatar"; +import { StarRatingDisplay } from "~/components/star-rating"; import { getCurrentUserId } from "~/lib/session"; import { formatPrice } from "~/lib/utils"; import { getUserEnrolledCourses } from "~/services/enrollmentService"; import { calculateProgress, getCompletedLessonCount } from "~/services/progressService"; +import { getRatingStatsForCourses } from "~/services/ratingService"; import { resolveCountry } from "~/lib/country.server"; import { calculatePppPrice } from "~/lib/ppp"; @@ -55,17 +57,22 @@ export async function loader({ request }: Route.LoaderArgs) { } } + const ratingStats = getRatingStatsForCourses(courses.map((c) => c.id)); + const coursesWithLessonCount = courses.map((course) => { const userProgress = progressMap.get(course.id); const pppPrice = course.pppEnabled ? calculatePppPrice(course.price, country) : course.price; + const stats = ratingStats.get(course.id) ?? { average: null, count: 0 }; return { ...course, lessonCount: getLessonCountForCourse(course.id), progress: userProgress?.progress ?? null, completedLessons: userProgress?.completedLessons ?? null, pppPrice, + ratingAverage: stats.average, + ratingCount: stats.count, }; }); @@ -209,6 +216,13 @@ export default function CourseCatalog({ loaderData }: Route.ComponentProps) {

{course.description}

+
+ +
{course.progress !== null && course.progress > 0 && ( diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 6a8747f4..c8dc2325 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -8,8 +8,10 @@ import { getAllCategories } from "~/services/categoryService"; import { CourseStatus } from "~/db/schema"; import { BookOpen, GraduationCap, Users, ArrowRight, User, Moon, Sun } from "lucide-react"; import { CourseImage } from "~/components/course-image"; +import { StarRatingDisplay } from "~/components/star-rating"; import { DevUI } from "~/components/dev-ui"; import { getAllUsers, getUserById } from "~/services/userService"; +import { getRatingStatsForCourses } from "~/services/ratingService"; import { getCurrentUserId, getDevCountry } from "~/lib/session"; import { getCountryTierInfo, COUNTRIES } from "~/lib/ppp"; @@ -22,10 +24,19 @@ export function meta({}: Route.MetaArgs) { export async function loader({ request }: Route.LoaderArgs) { const courses = buildCourseQuery(null, null, CourseStatus.Published, "newest", 50, 0); - const featured = courses.slice(0, 3).map((course) => ({ - ...course, - lessonCount: getLessonCountForCourse(course.id), - })); + const featuredCoursesRaw = courses.slice(0, 3); + const ratingStats = getRatingStatsForCourses( + featuredCoursesRaw.map((c) => c.id) + ); + const featured = featuredCoursesRaw.map((course) => { + const stats = ratingStats.get(course.id) ?? { average: null, count: 0 }; + return { + ...course, + lessonCount: getLessonCountForCourse(course.id), + ratingAverage: stats.average, + ratingCount: stats.count, + }; + }); const categories = getAllCategories(); const users = getAllUsers(); const currentUserId = await getCurrentUserId(request); @@ -186,6 +197,13 @@ export default function Home({ loaderData }: Route.ComponentProps) {

{course.description}

+
+ +
diff --git a/app/services/ratingService.test.ts b/app/services/ratingService.test.ts new file mode 100644 index 00000000..06f23cfe --- /dev/null +++ b/app/services/ratingService.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { createTestDb, seedBaseData } from "~/test/setup"; +import * as schema from "~/db/schema"; + +let testDb: ReturnType; +let base: ReturnType; + +vi.mock("~/db", () => ({ + get db() { + return testDb; + }, +})); + +import { + getUserRating, + upsertRating, + getCourseRatingStats, + getRatingStatsForCourses, +} from "./ratingService"; + +function makeUser(name: string, email: string) { + return testDb + .insert(schema.users) + .values({ name, email, role: schema.UserRole.Student }) + .returning() + .get(); +} + +function makeCourse(title: string, slug: string) { + return testDb + .insert(schema.courses) + .values({ + title, + slug, + description: "desc", + instructorId: base.instructor.id, + categoryId: base.category.id, + status: schema.CourseStatus.Published, + }) + .returning() + .get(); +} + +describe("ratingService", () => { + beforeEach(() => { + testDb = createTestDb(); + base = seedBaseData(testDb); + }); + + describe("upsertRating", () => { + it("inserts a rating on first call", () => { + const result = upsertRating(base.user.id, base.course.id, 4); + + expect(result.userId).toBe(base.user.id); + expect(result.courseId).toBe(base.course.id); + expect(result.rating).toBe(4); + expect(result.createdAt).toBeDefined(); + }); + + it("updates the existing rating on second call from the same user", () => { + const first = upsertRating(base.user.id, base.course.id, 3); + const second = upsertRating(base.user.id, base.course.id, 5); + + expect(second.id).toBe(first.id); + expect(second.rating).toBe(5); + expect(second.createdAt).toBe(first.createdAt); + + // Only one row total + const allForCourse = testDb + .select() + .from(schema.ratings) + .where(eq(schema.ratings.courseId, base.course.id)) + .all(); + expect(allForCourse).toHaveLength(1); + }); + + it("throws when rating is below 1", () => { + expect(() => upsertRating(base.user.id, base.course.id, 0)).toThrow(); + }); + + it("throws when rating is above 5", () => { + expect(() => upsertRating(base.user.id, base.course.id, 6)).toThrow(); + }); + + it("throws when rating is not an integer", () => { + expect(() => upsertRating(base.user.id, base.course.id, 3.5)).toThrow(); + }); + + it("allows different users to rate the same course independently", () => { + const u2 = makeUser("Second", "second@example.com"); + upsertRating(base.user.id, base.course.id, 5); + upsertRating(u2.id, base.course.id, 1); + + const all = testDb + .select() + .from(schema.ratings) + .where(eq(schema.ratings.courseId, base.course.id)) + .all(); + expect(all).toHaveLength(2); + }); + }); + + describe("unique constraint", () => { + it("rejects a second raw insert for the same user/course", () => { + testDb + .insert(schema.ratings) + .values({ userId: base.user.id, courseId: base.course.id, rating: 4 }) + .run(); + + expect(() => { + testDb + .insert(schema.ratings) + .values({ + userId: base.user.id, + courseId: base.course.id, + rating: 5, + }) + .run(); + }).toThrow(); + }); + }); + + describe("getUserRating", () => { + it("returns undefined when the user has not rated", () => { + expect(getUserRating(base.user.id, base.course.id)).toBeUndefined(); + }); + + it("returns the rating when present", () => { + upsertRating(base.user.id, base.course.id, 4); + const r = getUserRating(base.user.id, base.course.id); + expect(r).toBeDefined(); + expect(r!.rating).toBe(4); + }); + }); + + describe("getCourseRatingStats", () => { + it("returns null average and 0 count for an unrated course", () => { + const stats = getCourseRatingStats(base.course.id); + expect(stats.average).toBeNull(); + expect(stats.count).toBe(0); + }); + + it("computes average and count across multiple raters", () => { + const u2 = makeUser("Two", "two@example.com"); + const u3 = makeUser("Three", "three@example.com"); + + upsertRating(base.user.id, base.course.id, 4); + upsertRating(u2.id, base.course.id, 5); + upsertRating(u3.id, base.course.id, 3); + + const stats = getCourseRatingStats(base.course.id); + expect(stats.count).toBe(3); + expect(stats.average).toBeCloseTo(4, 5); + }); + }); + + describe("getRatingStatsForCourses", () => { + it("returns an empty map for an empty input array", () => { + const result = getRatingStatsForCourses([]); + expect(result.size).toBe(0); + }); + + it("returns stats only for courses that have ratings", () => { + const c2 = makeCourse("Course Two", "course-two"); + const c3 = makeCourse("Course Three", "course-three"); + const u2 = makeUser("Two", "two@example.com"); + + // base.course: avg 4 from 2 ratings + upsertRating(base.user.id, base.course.id, 3); + upsertRating(u2.id, base.course.id, 5); + // c2: avg 2 from 1 rating + upsertRating(base.user.id, c2.id, 2); + // c3: no ratings + + const result = getRatingStatsForCourses([ + base.course.id, + c2.id, + c3.id, + ]); + + expect(result.get(base.course.id)).toEqual({ average: 4, count: 2 }); + expect(result.get(c2.id)).toEqual({ average: 2, count: 1 }); + expect(result.has(c3.id)).toBe(false); + }); + }); +}); diff --git a/app/services/ratingService.ts b/app/services/ratingService.ts new file mode 100644 index 00000000..9c00ccd7 --- /dev/null +++ b/app/services/ratingService.ts @@ -0,0 +1,82 @@ +import { eq, and, inArray, sql } from "drizzle-orm"; +import { db } from "~/db"; +import { ratings } from "~/db/schema"; + +export type CourseRatingStats = { + average: number | null; + count: number; +}; + +export function getUserRating(userId: number, courseId: number) { + return db + .select() + .from(ratings) + .where(and(eq(ratings.userId, userId), eq(ratings.courseId, courseId))) + .get(); +} + +export function upsertRating( + userId: number, + courseId: number, + rating: number +) { + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + throw new Error("Rating must be an integer between 1 and 5"); + } + + const existing = getUserRating(userId, courseId); + + if (existing) { + return db + .update(ratings) + .set({ rating, updatedAt: new Date().toISOString() }) + .where(eq(ratings.id, existing.id)) + .returning() + .get(); + } + + return db + .insert(ratings) + .values({ userId, courseId, rating }) + .returning() + .get(); +} + +export function getCourseRatingStats(courseId: number): CourseRatingStats { + const result = db + .select({ + average: sql`avg(${ratings.rating})`, + count: sql`count(*)`, + }) + .from(ratings) + .where(eq(ratings.courseId, courseId)) + .get(); + + return { + average: result?.average ?? null, + count: result?.count ?? 0, + }; +} + +export function getRatingStatsForCourses( + courseIds: number[] +): Map { + const stats = new Map(); + if (courseIds.length === 0) return stats; + + const rows = db + .select({ + courseId: ratings.courseId, + average: sql`avg(${ratings.rating})`, + count: sql`count(*)`, + }) + .from(ratings) + .where(inArray(ratings.courseId, courseIds)) + .groupBy(ratings.courseId) + .all(); + + for (const row of rows) { + stats.set(row.courseId, { average: row.average, count: row.count }); + } + return stats; +} diff --git a/drizzle/0003_thankful_nitro.sql b/drizzle/0003_thankful_nitro.sql new file mode 100644 index 00000000..93289b5b --- /dev/null +++ b/drizzle/0003_thankful_nitro.sql @@ -0,0 +1,12 @@ +CREATE TABLE `ratings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `course_id` integer NOT NULL, + `rating` integer NOT NULL, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `ratings_user_course_unique` ON `ratings` (`user_id`,`course_id`); \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..b8eba3c2 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1341 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "7e453087-294a-44ad-aaf8-a40e65b9b969", + "prevId": "4df4a2bc-2402-47ab-a1fa-61e39d1627c7", + "tables": { + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "coupons": { + "name": "coupons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course_id": { + "name": "course_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purchase_id": { + "name": "purchase_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redeemed_by_user_id": { + "name": "redeemed_by_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "coupons_code_unique": { + "name": "coupons_code_unique", + "columns": [ + "code" + ], + "isUnique": true + } + }, + "foreignKeys": { + "coupons_team_id_teams_id_fk": { + "name": "coupons_team_id_teams_id_fk", + "tableFrom": "coupons", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupons_course_id_courses_id_fk": { + "name": "coupons_course_id_courses_id_fk", + "tableFrom": "coupons", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupons_purchase_id_purchases_id_fk": { + "name": "coupons_purchase_id_purchases_id_fk", + "tableFrom": "coupons", + "tableTo": "purchases", + "columnsFrom": [ + "purchase_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "coupons_redeemed_by_user_id_users_id_fk": { + "name": "coupons_redeemed_by_user_id_users_id_fk", + "tableFrom": "coupons", + "tableTo": "users", + "columnsFrom": [ + "redeemed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "courses": { + "name": "courses", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sales_copy": { + "name": "sales_copy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instructor_id": { + "name": "instructor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ppp_enabled": { + "name": "ppp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "courses_slug_unique": { + "name": "courses_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "courses_instructor_id_users_id_fk": { + "name": "courses_instructor_id_users_id_fk", + "tableFrom": "courses", + "tableTo": "users", + "columnsFrom": [ + "instructor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "courses_category_id_categories_id_fk": { + "name": "courses_category_id_categories_id_fk", + "tableFrom": "courses", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "enrollments": { + "name": "enrollments", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course_id": { + "name": "course_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "enrollments_user_id_users_id_fk": { + "name": "enrollments_user_id_users_id_fk", + "tableFrom": "enrollments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "enrollments_course_id_courses_id_fk": { + "name": "enrollments_course_id_courses_id_fk", + "tableFrom": "enrollments", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lesson_progress": { + "name": "lesson_progress", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "lesson_progress_user_id_users_id_fk": { + "name": "lesson_progress_user_id_users_id_fk", + "tableFrom": "lesson_progress", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "lesson_progress_lesson_id_lessons_id_fk": { + "name": "lesson_progress_lesson_id_lessons_id_fk", + "tableFrom": "lesson_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "lessons": { + "name": "lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "module_id": { + "name": "module_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "video_url": { + "name": "video_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_repo_url": { + "name": "github_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_module_id_modules_id_fk": { + "name": "lessons_module_id_modules_id_fk", + "tableFrom": "lessons", + "tableTo": "modules", + "columnsFrom": [ + "module_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "modules": { + "name": "modules", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "course_id": { + "name": "course_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "modules_course_id_courses_id_fk": { + "name": "modules_course_id_courses_id_fk", + "tableFrom": "modules", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "purchases": { + "name": "purchases", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course_id": { + "name": "course_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_paid": { + "name": "price_paid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "purchases_user_id_users_id_fk": { + "name": "purchases_user_id_users_id_fk", + "tableFrom": "purchases", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "purchases_course_id_courses_id_fk": { + "name": "purchases_course_id_courses_id_fk", + "tableFrom": "purchases", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_answers": { + "name": "quiz_answers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_id": { + "name": "question_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_option_id": { + "name": "selected_option_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_answers_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_answers_selected_option_id_quiz_options_id_fk": { + "name": "quiz_answers_selected_option_id_quiz_options_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_options", + "columnsFrom": [ + "selected_option_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_attempts": { + "name": "quiz_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "quiz_id": { + "name": "quiz_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passed": { + "name": "passed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempted_at": { + "name": "attempted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_options": { + "name": "quiz_options", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "question_id": { + "name": "question_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "option_text": { + "name": "option_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_correct": { + "name": "is_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_options_question_id_quiz_questions_id_fk": { + "name": "quiz_options_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_options", + "tableTo": "quiz_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quiz_questions": { + "name": "quiz_questions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question_type": { + "name": "question_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "quizzes": { + "name": "quizzes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "passing_score": { + "name": "passing_score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "quizzes_lesson_id_lessons_id_fk": { + "name": "quizzes_lesson_id_lessons_id_fk", + "tableFrom": "quizzes", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ratings": { + "name": "ratings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "course_id": { + "name": "course_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ratings_user_course_unique": { + "name": "ratings_user_course_unique", + "columns": [ + "user_id", + "course_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ratings_course_id_courses_id_fk": { + "name": "ratings_course_id_courses_id_fk", + "tableFrom": "ratings", + "tableTo": "courses", + "columnsFrom": [ + "course_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "team_members": { + "name": "team_members", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "video_watch_events": { + "name": "video_watch_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position_seconds": { + "name": "position_seconds", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "video_watch_events_user_id_users_id_fk": { + "name": "video_watch_events_user_id_users_id_fk", + "tableFrom": "video_watch_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "video_watch_events_lesson_id_lessons_id_fk": { + "name": "video_watch_events_lesson_id_lessons_id_fk", + "tableFrom": "video_watch_events", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index b835c39a..7e82abe9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1770650640202, "tag": "0002_lying_shriek", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777483383476, + "tag": "0003_thankful_nitro", + "breakpoints": true } ] } \ No newline at end of file From b8bbcf5fcc24a47a8ba9eed1e2dfebe48553ec25 Mon Sep 17 00:00:00 2001 From: gladyshav Date: Sat, 2 May 2026 20:27:07 +0300 Subject: [PATCH 2/4] Add lesson commenting system with instructor moderation Students can post markdown comments on lessons they're enrolled in. Instructors and admins can hide (reversible) or permanently delete comments, both inline on lesson pages and from a centralized Comments tab in the instructor dashboard. Co-Authored-By: Claude Opus 4.6 --- app/components/lesson-comments.tsx | 233 +++ app/db/schema.ts | 23 + .../courses.$slug.lessons.$lessonId.tsx | 130 +- app/routes/instructor.$courseId.tsx | 153 +- app/services/commentService.ts | 105 ++ drizzle/0004_old_miracleman.sql | 11 + drizzle/meta/0004_snapshot.json | 1427 +++++++++++++++++ drizzle/meta/_journal.json | 7 + 8 files changed, 2085 insertions(+), 4 deletions(-) create mode 100644 app/components/lesson-comments.tsx create mode 100644 app/services/commentService.ts create mode 100644 drizzle/0004_old_miracleman.sql create mode 100644 drizzle/meta/0004_snapshot.json diff --git a/app/components/lesson-comments.tsx b/app/components/lesson-comments.tsx new file mode 100644 index 00000000..a799962f --- /dev/null +++ b/app/components/lesson-comments.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect, useRef } from "react"; +import { useFetcher } from "react-router"; +import { toast } from "sonner"; +import { MessageSquare, EyeOff, Eye, Trash2 } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { Textarea } from "~/components/ui/textarea"; +import { UserAvatar } from "~/components/user-avatar"; +import { cn } from "~/lib/utils"; + +type Comment = { + id: number; + userId: number; + content: string; + contentHtml: string; + status: string; + createdAt: string; + authorName: string; + authorAvatarUrl: string | null; +}; + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths}mo ago`; + + return `${Math.floor(diffMonths / 12)}y ago`; +} + +export function LessonComments({ + comments, + lessonId, + currentUserId, + isInstructorOrAdmin, + canComment, +}: { + comments: Comment[]; + lessonId: number; + currentUserId: number | null; + isInstructorOrAdmin: boolean; + canComment: boolean; +}) { + const postFetcher = useFetcher({ key: `post-comment-${lessonId}` }); + const [content, setContent] = useState(""); + const textareaRef = useRef(null); + + const isPosting = postFetcher.state !== "idle"; + + useEffect(() => { + if (postFetcher.state === "idle" && postFetcher.data?.commentPosted) { + setContent(""); + toast.success("Comment posted"); + } + }, [postFetcher.state, postFetcher.data]); + + const visibleCount = comments.filter((c) => c.status === "visible").length; + + return ( +
+
+ +

Discussion

+ + ({visibleCount}) + +
+ + {canComment && ( + + +