Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 189 additions & 119 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"autoprefixer": "^10.4.15",
"canvas-confetti": "1.9.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
Expand All @@ -59,9 +60,10 @@
"embla-carousel-react": "^8.3.0",
"eslint": "8.49.0",
"eslint-config-next": "13.5.1",
"framer-motion": "11.18.0",
"input-otp": "^1.2.4",
"lucide-react": "^0.446.0",
"next": "13.5.1",
"next": "13.5.11",
"next-themes": "^0.3.0",
"postcss": "^8.4.30",
"react": "18.2.0",
Expand All @@ -82,6 +84,7 @@
"zod": "^3.24.3"
},
"devDependencies": {
"@types/canvas-confetti": "1.6.4",
"@types/crypto-js": "^4.2.2",
"dotenv": "^17.0.1",
"ts-node": "^10.9.2",
Expand Down
22 changes: 22 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "RateMyCourse - IIT Bhilai",
"short_name": "RateMyCourse",
"description": "Rate and review courses and professors at IIT Bhilai",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#7c3aed",
"orientation": "any",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
31 changes: 31 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const CACHE_NAME = "ratemycourse-v1";
const STATIC_ASSETS = ["/", "/courses", "/professors", "/about"];

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});

self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
event.respondWith(
fetch(event.request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
})
.catch(() => caches.match(event.request))
);
});
66 changes: 66 additions & 0 deletions src/app/api/auth/anonymize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { NextResponse } from 'next/server';
import { randomBytes, pbkdf2 } from 'crypto';
import { promisify } from 'util';
import { v4 as uuidv4 } from 'uuid';
import { createClient } from '@/utils/supabase/server';

export const runtime = 'nodejs';

const pbkdf2Async = promisify(pbkdf2);

export async function POST() {
const supabase = await createClient();
const { data: { session } } = await supabase.auth.getSession();

if (!session?.user?.id || !session.user.email) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const authId = session.user.id;
const email = session.user.email;

const { data: existing, error: lookupError } = await supabase
.from('users')
.select('anonymous_id')
.eq('auth_id', authId)
.maybeSingle();

if (lookupError) {
return NextResponse.json({ error: 'Lookup failed' }, { status: 500 });
}

if (existing) {
return NextResponse.json({ anonymousId: existing.anonymous_id });
}

const primarySalt = randomBytes(32).toString('hex');
const primaryHash = await pbkdf2Async(email, primarySalt, 100000, 64, 'sha512');
const verificationToken = primaryHash.toString('hex').slice(0, 64);

const secondarySalt = randomBytes(32).toString('hex');
const verificationHashBuf = await pbkdf2Async(
verificationToken,
secondarySalt,
50000,
64,
'sha512',
);
const verificationHash = verificationHashBuf.toString('hex');

const anonymousId = uuidv4();

const { error: insertError } = await supabase
.from('users')
.insert({
auth_id: authId,
anonymous_id: anonymousId,
verification_hash: verificationHash,
salt: secondarySalt,
});

if (insertError) {
return NextResponse.json({ error: 'Failed to store identity' }, { status: 500 });
}

return NextResponse.json({ anonymousId });
}
154 changes: 154 additions & 0 deletions src/app/compare/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client";

import { useState, useEffect } from "react";
import { supabase } from "@/lib/supabase";
import { Search, X, ArrowLeftRight } from "lucide-react";
import { motion } from "framer-motion";

interface CourseData {
id: string;
code: string;
title: string;
department: string;
credits: number;
overall_rating: number;
difficulty_rating: number;
workload_rating: number;
review_count: number;
}

function RatingBar({ label, value, max = 5 }: { label: string; value: number; max?: number }) {
const pct = (value / max) * 100;
const color = value >= 4 ? "bg-green-500" : value >= 3 ? "bg-yellow-500" : value >= 2 ? "bg-orange-500" : "bg-red-500";
return (
<div className="space-y-1">
<div className="flex justify-between text-xs font-mono">
<span className="text-muted-foreground">{label}</span>
<span className="font-bold text-primary">{value.toFixed(1)}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.6, ease: "easeOut" }}
className={`h-full rounded-full ${color}`}
/>
</div>
</div>
);
}

function CourseSelector({ onSelect, selected }: { onSelect: (c: CourseData) => void; selected: CourseData | null }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<CourseData[]>([]);

useEffect(() => {
if (query.length < 2) { setResults([]); return; }
const t = setTimeout(async () => {
const { data } = await supabase
.from("courses")
.select("id, code, title, department, credits, overall_rating, difficulty_rating, workload_rating, review_count")
.or(`title.ilike.%${query}%,code.ilike.%${query}%`)
.limit(6);
setResults(data || []);
}, 300);
return () => clearTimeout(t);
}, [query]);

if (selected) {
return (
<div className="p-4 rounded-xl border border-primary/30 bg-primary/5 space-y-1">
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-primary">{selected.code}</span>
<button onClick={() => onSelect(null as any)} className="p-1 rounded hover:bg-accent">
<X className="h-4 w-4" />
</button>
</div>
<p className="font-semibold text-sm">{selected.title}</p>
<p className="text-xs text-muted-foreground">{selected.department} · {selected.credits} credits</p>
</div>
);
}

return (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search course..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-lg border border-border/60 bg-card/80 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{results.length > 0 && (
<div className="border border-border/40 rounded-lg overflow-hidden bg-background/95 shadow-lg">
{results.map((c) => (
<button
key={c.id}
onClick={() => { onSelect(c); setQuery(""); setResults([]); }}
className="w-full text-left px-4 py-2.5 hover:bg-accent/50 border-b border-border/20 last:border-0 transition-colors"
>
<span className="text-xs font-mono font-bold text-primary">{c.code}</span>
<span className="text-sm ml-2">{c.title}</span>
</button>
))}
</div>
)}
</div>
);
}

export default function ComparePage() {
const [courseA, setCourseA] = useState<CourseData | null>(null);
const [courseB, setCourseB] = useState<CourseData | null>(null);

const canCompare = courseA && courseB;

return (
<div className="relative min-h-screen bg-background">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48cGF0dGVybiBpZD0iZ3JpZCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBwYXR0ZXJuVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48cGF0aCBkPSJNIDQwIDAgTCAwIDAgMCA0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIHN0cm9rZS13aWR0aD0iMSIgb3BhY2l0eT0iMC4xNSIvPjwvcGF0dGVybj48L2RlZnM+PHJlY3Qgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgZmlsbD0idXJsKCNncmlkKSIvPjwvc3ZnPg==')] opacity-40 dark:opacity-60" />

<div className="relative z-10 max-w-4xl mx-auto px-4 py-10">
<div className="text-center mb-8">
<h1 className="text-3xl sm:text-4xl font-black tracking-tight">
<span className="text-primary font-mono">Compare</span> Courses
</h1>
<p className="text-sm text-muted-foreground mt-2">Select two courses to compare side by side</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] gap-4 items-start mb-8">
<CourseSelector onSelect={setCourseA} selected={courseA} />
<div className="hidden md:flex items-center justify-center pt-2">
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
</div>
<CourseSelector onSelect={setCourseB} selected={courseB} />
</div>

{canCompare && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="grid grid-cols-1 md:grid-cols-2 gap-6"
>
{[courseA, courseB].map((course) => (
<div key={course.id} className="p-6 rounded-xl border border-border/60 bg-card/50 backdrop-blur-sm space-y-4">
<div>
<span className="text-xs font-mono font-bold text-primary">{course.code}</span>
<h3 className="font-bold text-lg mt-1">{course.title}</h3>
<p className="text-xs text-muted-foreground">{course.department} · {course.credits} credits · {course.review_count} reviews</p>
</div>
<div className="space-y-3">
<RatingBar label="Overall" value={course.overall_rating || 0} />
<RatingBar label="Difficulty" value={course.difficulty_rating || 0} />
<RatingBar label="Workload" value={course.workload_rating || 0} />
</div>
</div>
))}
</motion.div>
)}
</div>
</div>
);
}
11 changes: 10 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import { ThemeProvider } from '@/components/theme/theme-provider';
import './globals.css'
import Header from '@/components/layout/Header';
import Footer from '@/components/layout/Footer';
import ScrollToTop from '@/components/layout/ScrollToTop';
import PageTransition from '@/components/layout/PageTransition';
import ServiceWorkerRegister from '@/components/layout/ServiceWorkerRegister';
import { AuthProvider } from '@/contexts/AuthContext';
import { Toaster } from 'react-hot-toast';
const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
title: 'RateMyCourse - IIT Bhilai',
description: 'Find and review courses and professors at IIT Bhilai',
manifest: '/manifest.json',
themeColor: '#7c3aed',
};


Expand All @@ -31,12 +36,16 @@ export default function RootLayout({
disableTransitionOnChange
>
<div className="flex flex-col">
<ScrollToTop />
<Header />
<div className="flex-1">{children}</div>
<div className="flex-1">
<PageTransition>{children}</PageTransition>
</div>
<Footer />
</div>
{/* Toast notifications */}
<Toaster position="top-center" />
<ServiceWorkerRegister />
</ThemeProvider>
</AuthProvider>
</body>
Expand Down
37 changes: 7 additions & 30 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { BookOpen, Users } from "lucide-react";
import { supabase } from "@/lib/supabase";
import SearchBar from "@/components/common/SearchBar";
import TrendingSection from "@/components/common/TrendingSection";
import dynamic from 'next/dynamic';

// Import Typewriter dynamically to prevent SSR issues
Expand Down Expand Up @@ -122,36 +124,9 @@ export default function Home() {
</div>
</div>

{/* <div className="flex flex-col sm:flex-row w-full max-w-2xl gap-3 mt-4">
<div className="relative flex-1">
<SearchIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<input
type="text"
placeholder="Search for courses or professors..."
className="pl-12 pr-4 py-3 w-full rounded-lg border border-input bg-card/80 backdrop-blur-sm shadow-md focus:ring-2 focus:ring-ring/30 focus:border-ring transition-all"
/>
</div>
<Link
href="/courses"
className={buttonVariants({
size: "lg",
className: "px-8 py-3 font-medium shadow-md"
})}
>
Search
</Link>
</div> */}
{/* <Link
href="/review/new"
className={buttonVariants({
variant: "secondary",
size: "lg",
className: "shadow-md font-medium"
})}
>
<PenLine className="h-4 w-4 mr-2" />
Write Your Review!
</Link> */}
<div className="w-full max-w-2xl px-4 sm:px-0">
<SearchBar />
</div>

<div className="grid grid-cols-3 gap-2 sm:gap-4 w-full max-w-2xl border border-border/60 rounded-lg p-4 sm:p-6 md:p-8 bg-card/50 hover:border-primary/30 hover:bg-card/60 transition-all duration-300 backdrop-blur-sm">
<div className="flex flex-col items-center space-y-1 sm:space-y-2 group cursor-default">
Expand Down Expand Up @@ -180,6 +155,8 @@ export default function Home() {
</div>
</div>

<TrendingSection />

<div className="flex flex-col sm:flex-row gap-2 sm:gap-3 w-full sm:w-auto px-4 sm:px-0">
<Link
href="/courses"
Expand Down
Loading