diff --git a/frontend/app/api/leaderboard/route.ts b/frontend/app/api/leaderboard/route.ts new file mode 100644 index 0000000..d9b31e0 --- /dev/null +++ b/frontend/app/api/leaderboard/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient } from "@/utils/supabaseServerClient"; + +type LeaderboardRow = { + id: string; + handle: string | null; + display_name: string | null; + uploaded_note_count: number | null; + total_credits_earned: number | null; + credit_score: number | null; +}; + +function buildInitials(displayName: string | null, handle: string | null): string { + const source = (displayName?.trim() || handle?.trim() || "U").replace(/^@/, ""); + const parts = source.split(/\s+/).filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + return source.slice(0, 2).toUpperCase(); +} + +export async function GET() { + const headerStore = await headers(); + const authHeader = headerStore.get("authorization"); + const bearerToken = authHeader?.toLowerCase().startsWith("bearer ") + ? authHeader.split(" ")[1]?.trim() + : null; + + const supabase = await createClient(bearerToken); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + const status = userError.status === 401 ? 401 : 500; + const message = + userError.status === 401 || userError.message === "Auth session missing!" + ? "Not authenticated" + : userError.message; + return NextResponse.json({ error: message }, { status }); + } + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + } + + const { data, error } = await supabase + .from("profiles") + .select("id, handle, display_name, uploaded_note_count, total_credits_earned, credit_score") + .order("uploaded_note_count", { ascending: false }) + .order("total_credits_earned", { ascending: false }) + .order("credit_score", { ascending: false }) + .order("created_at", { ascending: true }) + .limit(100) + .returns(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + const entries = (data ?? []).map((row, index) => ({ + rank: index + 1, + userId: row.id, + name: row.display_name?.trim() || row.handle || "Anonymous", + uploads: Number(row.uploaded_note_count ?? 0), + credits: Number(row.credit_score ?? 0), + avatar: buildInitials(row.display_name, row.handle), + })); + + return NextResponse.json({ leaderboard: entries }, { status: 200 }); +} diff --git a/frontend/app/auth/page.tsx b/frontend/app/auth/page.tsx index f48ec42..4f6adaf 100644 --- a/frontend/app/auth/page.tsx +++ b/frontend/app/auth/page.tsx @@ -31,7 +31,7 @@ export default function AuthPage() { }; checkSession(); - }, [router]); + }, [router, redirectTo]); const content = checking ? (

Checking your session…

diff --git a/frontend/app/components/ThemeProvider.tsx b/frontend/app/components/ThemeProvider.tsx index 7937432..dcc18eb 100644 --- a/frontend/app/components/ThemeProvider.tsx +++ b/frontend/app/components/ThemeProvider.tsx @@ -25,15 +25,11 @@ type ThemeContextValue = { const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState("light"); - const [mounted, setMounted] = useState(false); + const [theme, setThemeState] = useState(() => getStoredTheme()); useEffect(() => { - const stored = getStoredTheme(); - setThemeState(stored); - applyTheme(stored); - setMounted(true); - }, []); + applyTheme(theme); + }, [theme]); const setTheme = useCallback((next: Theme) => { setThemeState(next); @@ -45,8 +41,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { setTheme(theme === "light" ? "dark" : "light"); }, [theme, setTheme]); - if (!mounted) return <>{children}; - return ( {children} diff --git a/frontend/app/dashboard/course/[classId]/page.tsx b/frontend/app/dashboard/course/[classId]/page.tsx index 25eefc2..b382173 100644 --- a/frontend/app/dashboard/course/[classId]/page.tsx +++ b/frontend/app/dashboard/course/[classId]/page.tsx @@ -7,7 +7,7 @@ import { useState, type FormEvent, } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import Link from "next/link"; import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient"; import PDFThumbnail from "@/app/components/pdf/PDFThumbnail"; @@ -56,7 +56,6 @@ function termYearLabel(term: string | null, year: number | null): string { export default function CourseDetailPage() { const params = useParams(); - const router = useRouter(); const classId = typeof params.classId === "string" ? params.classId : null; const [course, setCourse] = useState(null); @@ -341,11 +340,10 @@ export default function CourseDetailPage() { } return updated; }); - if (selectedNote?.id === noteId) { - setSelectedNote((prev) => - prev ? { ...prev, downloaded: true } : null, - ); - } + setSelectedNote((prev) => { + if (!prev || prev.id !== noteId) return prev; + return { ...prev, downloaded: true }; + }); } catch { setDownloadError("Failed to download note. Try again."); } finally { @@ -357,7 +355,6 @@ export default function CourseDetailPage() { downloadingId, refreshToken, fetchCredits, - selectedNote?.id, ], ); @@ -450,7 +447,7 @@ export default function CourseDetailPage() { setVotingId(null); } }, - [accessToken, votingId, refreshToken, fetchCredits, selectedNote], + [accessToken, votingId, refreshToken, fetchCredits], ); const handleOpenNoteModal = (note: Note) => { @@ -536,6 +533,9 @@ export default function CourseDetailPage() { rightSlot={ <> Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes @@ -556,6 +556,9 @@ export default function CourseDetailPage() { rightSlot={ <> Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index f060e58..22e00c7 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -39,13 +39,6 @@ type CourseOption = { note_count: number; }; -function termYearLabel(term: string | null, year: number | null): string { - if (term && year != null) return `${term} ${year}`; - if (term) return term; - if (year != null) return String(year); - return "—"; -} - export default function DashboardPage() { const [courses, setCourses] = useState([]); const [selectedDepartment, setSelectedDepartment] = useState(null); @@ -58,7 +51,7 @@ export default function DashboardPage() { const [coursesLoading, setCoursesLoading] = useState(false); const [coursesLoadingMore, setCoursesLoadingMore] = useState(false); const [catalogTerms, setCatalogTerms] = useState(FALLBACK_CATALOG_TERMS); - const [departments, setDepartments] = useState(() => [...CALPOLY_DEPARTMENT_CODES]); + const [departments] = useState(() => [...CALPOLY_DEPARTMENT_CODES]); const [credits, setCredits] = useState(null); const [freeDownloads, setFreeDownloads] = useState(null); /** Number of course cards to render (paginated for performance). */ @@ -66,7 +59,7 @@ export default function DashboardPage() { /** When no department selected, whether the API has more courses to fetch. */ const [hasMoreFromApi, setHasMoreFromApi] = useState(false); /** Page enter animation (leaderboard-style). */ - const [isVisible, setIsVisible] = useState(false); + const [isVisible] = useState(true); const refreshToken = useCallback(async () => { const { session, error } = await getSessionWithRecovery(supabase); @@ -76,10 +69,6 @@ export default function DashboardPage() { return newToken; }, []); - useEffect(() => { - setIsVisible(true); - }, []); - useEffect(() => { const loadSession = async () => { const { session, error } = await getSessionWithRecovery(supabase); @@ -133,22 +122,23 @@ export default function DashboardPage() { ); useEffect(() => { - if (!tokenLoaded || !accessToken) { - if (tokenLoaded && !accessToken) { - setCoursesError("Not authenticated"); - setCoursesLoading(false); - } - return; - } + if (!tokenLoaded || !accessToken) return; let active = true; - setCoursesLoading(true); - setCoursesLoadingMore(false); + const loadingTimer = window.setTimeout(() => { + if (!active) return; + setCoursesLoading(true); + setCoursesLoadingMore(false); + }, 0); const pageSize = selectedDepartment ? DEPARTMENT_PAGE_SIZE : INITIAL_PAGE_SIZE; const run = async () => { try { - let token = accessToken; - let res = await fetchCoursesPage(token, 0, selectedDepartment, pageSize); + let res = await fetchCoursesPage( + accessToken, + 0, + selectedDepartment, + pageSize, + ); if (res.ok === false && res.error === "Not authenticated") { const newToken = await refreshToken(); if (newToken) res = await fetchCoursesPage(newToken, 0, selectedDepartment, pageSize); @@ -178,7 +168,10 @@ export default function DashboardPage() { } }; run(); - return () => { active = false; }; + return () => { + active = false; + window.clearTimeout(loadingTimer); + }; }, [accessToken, tokenLoaded, selectedDepartment, refreshToken, fetchCoursesPage]); useEffect(() => { @@ -282,7 +275,10 @@ export default function DashboardPage() { /** Reset visible count when filters/search change so we don't show a short list after narrowing. */ useEffect(() => { - setVisibleCourseCount(80); + const timer = window.setTimeout(() => { + setVisibleCourseCount(80); + }, 0); + return () => window.clearTimeout(timer); }, [selectedDepartment, browseSearch]); const coursesToRender = allDisplayCourses.slice(0, visibleCourseCount); @@ -301,11 +297,17 @@ export default function DashboardPage() { let cancelled = false; (async () => { try { - let token = accessToken; - let res = await fetchCoursesPage(token, offset, null, INITIAL_PAGE_SIZE); - if (res === null) { + let res = await fetchCoursesPage( + accessToken, + offset, + null, + INITIAL_PAGE_SIZE, + ); + if (res.ok === false && res.error === "Not authenticated") { const newToken = await refreshToken(); - if (newToken) res = await fetchCoursesPage(newToken, offset, null, INITIAL_PAGE_SIZE); + if (newToken) { + res = await fetchCoursesPage(newToken, offset, null, INITIAL_PAGE_SIZE); + } } if (cancelled) return; if (res.ok) { @@ -341,6 +343,9 @@ export default function DashboardPage() { rightSlot={ <> Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes diff --git a/frontend/app/leaderboard/page.tsx b/frontend/app/leaderboard/page.tsx index f22a8b1..ad3d4c1 100644 --- a/frontend/app/leaderboard/page.tsx +++ b/frontend/app/leaderboard/page.tsx @@ -1,26 +1,75 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { DesignNav } from "@/app/components/DesignNav"; +import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient"; + +type LeaderboardEntry = { + rank: number; + userId: string; + name: string; + uploads: number; + credits: number; + avatar: string; +}; export default function LeaderboardPage() { const [isVisible, setIsVisible] = useState(false); - const [selectedPeriod, setSelectedPeriod] = useState("all-time"); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [accessToken, setAccessToken] = useState(null); useEffect(() => { setIsVisible(true); }, []); - const leaderboardData = [ - { rank: 1, name: "Sarah Johnson", uploads: 156, credits: 2340, avatar: "SJ" }, - { rank: 2, name: "Michael Chen", uploads: 142, credits: 2130, avatar: "MC" }, - { rank: 3, name: "Emily Davis", uploads: 128, credits: 1920, avatar: "ED" }, - { rank: 4, name: "James Wilson", uploads: 115, credits: 1725, avatar: "JW" }, - { rank: 5, name: "Jessica Brown", uploads: 98, credits: 1470, avatar: "JB" }, - { rank: 6, name: "David Martinez", uploads: 87, credits: 1305, avatar: "DM" }, - { rank: 7, name: "Ashley Taylor", uploads: 76, credits: 1140, avatar: "AT" }, - { rank: 8, name: "Christopher Lee", uploads: 65, credits: 975, avatar: "CL" }, - ]; + useEffect(() => { + const loadSession = async () => { + const { session } = await getSessionWithRecovery(supabase); + setAccessToken(session?.access_token ?? null); + }; + + void loadSession(); + }, []); + + const fetchLeaderboard = useCallback(async () => { + if (!accessToken) { + setLoading(false); + setEntries([]); + setError("Not authenticated"); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/leaderboard", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const payload = (await res.json().catch(() => ({}))) as { error?: string }; + setEntries([]); + setError(payload.error ?? "Failed to load leaderboard"); + return; + } + + const payload = (await res.json()) as { leaderboard?: LeaderboardEntry[] }; + setEntries(payload.leaderboard ?? []); + } catch { + setEntries([]); + setError("Failed to load leaderboard"); + } finally { + setLoading(false); + } + }, [accessToken]); + + useEffect(() => { + if (!accessToken) return; + void fetchLeaderboard(); + }, [accessToken, fetchLeaderboard]); const getMedalColor = (rank: number) => { if (rank === 1) return "from-yellow-400 to-yellow-600"; @@ -29,6 +78,12 @@ export default function LeaderboardPage() { return "from-[#6dbe8b] to-[#90bf8e]"; }; + const first = entries[0] ?? null; + const second = entries[1] ?? null; + const third = entries[2] ?? null; + const hasPodium = entries.length >= 3; + const listStartIndex = hasPodium ? 3 : 0; + return (
@@ -40,92 +95,84 @@ export default function LeaderboardPage() { }`} >

Leaderboard

-

Top contributors in the community

+

Ranked by total uploaded notes

-
-
- {["all-time", "month", "week"].map((period) => ( - - ))} -
-
-
-
-
-
- {leaderboardData[1].avatar} -
-
-
2
-

{leaderboardData[1].name.split(" ")[0]}

-

{leaderboardData[1].credits} pts

-
-
-
-
- {leaderboardData[0].avatar} -
-
-
1
-

{leaderboardData[0].name.split(" ")[0]}

-

{leaderboardData[0].credits} pts

+ + {loading &&

Loading leaderboard...

} + {!loading && error &&

{error}

} + {!loading && !error && entries.length === 0 && ( +

No contributors yet.

+ )} + + {!loading && !error && first && second && third && ( +
+
+
+
+ {second.avatar} +
+
+
2
+

{second.name.split(" ")[0]}

+

{second.uploads} uploads

+
-
-
-
- {leaderboardData[2].avatar} +
+
+ {first.avatar} +
+
+
1
+

{first.name.split(" ")[0]}

+

{first.uploads} uploads

+
-
-
3
-

{leaderboardData[2].name.split(" ")[0]}

-

{leaderboardData[2].credits} pts

+
+
+ {third.avatar} +
+
+
3
+

{third.name.split(" ")[0]}

+

{third.uploads} uploads

+
-
-
- {leaderboardData.slice(3).map((user, index) => ( -
-
-
{user.rank}
-
- {user.avatar} -
-
-

{user.name}

-

{user.uploads} uploads

-
-
-
{user.credits}
-
points
+ )} + + {!loading && !error && ( +
+ {entries.slice(listStartIndex).map((user, index) => ( +
+
+
{user.rank}
+
+ {user.avatar} +
+
+

{user.name}

+

{user.uploads} uploads

+
+
+
{user.credits}
+
credits
+
-
- ))} -
+ ))} +
+ )}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 7fce517..1ad59d2 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,4 +1,5 @@ "use client"; +/* eslint-disable @next/next/no-img-element */ import React, { useEffect, useRef, useState } from "react"; import Link from "next/link"; @@ -8,7 +9,7 @@ const ANIMA_IMG = "https://c.animaapp.com/vYVdVbUl/img"; export default function Home() { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [heroVisible, setHeroVisible] = useState(false); + const [heroVisible] = useState(true); const scrollToSection = (sectionId: string) => { const element = document.getElementById(sectionId); @@ -18,10 +19,6 @@ export default function Home() { } }; - useEffect(() => { - setHeroVisible(true); - }, []); - return (
(null); @@ -63,11 +62,7 @@ export default function UploadPage() { const [submitError, setSubmitError] = useState(null); const [isUploading, setIsUploading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - setIsVisible(true); - }, []); + const [isVisible] = useState(true); useEffect(() => { (async () => { @@ -77,7 +72,6 @@ export default function UploadPage() { router.replace("/auth"); return; } - setIsAuthenticated(true); })(); }, [router]); @@ -480,7 +474,6 @@ export default function UploadPage() { autoComplete="off" aria-invalid={!!classNotFoundError} aria-describedby={classNotFoundError ? "class-error" : undefined} - aria-expanded={isClassListOpen && matchingClasses.length > 0} aria-controls="class-results-list" /> {isClassListOpen && classNumberInput.trim() && department && ( @@ -499,6 +492,7 @@ export default function UploadPage() { key={c.id} type="button" role="option" + aria-selected={classId === c.id} className="upload-class-result-item" onMouseDown={(e) => { e.preventDefault(); @@ -528,6 +522,11 @@ export default function UploadPage() { {classNotFoundError}

)} + {classesError && !classNotFoundError && ( +

+ {classesError} +

+ )} ]+>/g, " ").replace(/\s+/g, " "); - const parsed = parseCoursesFromText(text, slug); + const parsed = parseCoursesFromText(text); for (const c of parsed) { const key = `${c.department}-${c.code}`; if (!coursesByKey.has(key)) { diff --git a/frontend/scripts/fetch-course-titles.mjs b/frontend/scripts/fetch-course-titles.mjs index 3c6f832..4815d38 100644 --- a/frontend/scripts/fetch-course-titles.mjs +++ b/frontend/scripts/fetch-course-titles.mjs @@ -103,7 +103,7 @@ async function run() { } process.stderr.write("."); await sleep(DELAY_MS); - } catch (err) { + } catch { failed.push(url); process.stderr.write("x"); } diff --git a/supabase/migrations/202602191130_add_uploaded_note_count_to_profiles.sql b/supabase/migrations/202602191130_add_uploaded_note_count_to_profiles.sql new file mode 100644 index 0000000..b035c42 --- /dev/null +++ b/supabase/migrations/202602191130_add_uploaded_note_count_to_profiles.sql @@ -0,0 +1,56 @@ +alter table public.profiles + add column if not exists uploaded_note_count integer not null default 0 + check (uploaded_note_count >= 0); + +-- Backfill from existing resources so the counter starts accurate. +update public.profiles p +set uploaded_note_count = c.upload_count +from ( + select p0.id as profile_id, count(r.id)::integer as upload_count + from public.profiles p0 + left join public.resources r on r.profile_id = p0.id + group by p0.id +) as c +where p.id = c.profile_id; + +create or replace function public.fn_sync_uploaded_note_count() +returns trigger +language plpgsql +as $$ +begin + if tg_op = 'INSERT' then + update public.profiles + set uploaded_note_count = uploaded_note_count + 1 + where id = new.profile_id; + return new; + end if; + + if tg_op = 'DELETE' then + update public.profiles + set uploaded_note_count = greatest(uploaded_note_count - 1, 0) + where id = old.profile_id; + return old; + end if; + + if tg_op = 'UPDATE' then + if new.profile_id is distinct from old.profile_id then + update public.profiles + set uploaded_note_count = greatest(uploaded_note_count - 1, 0) + where id = old.profile_id; + + update public.profiles + set uploaded_note_count = uploaded_note_count + 1 + where id = new.profile_id; + end if; + return new; + end if; + + return null; +end; +$$; + +drop trigger if exists trg_resources_sync_uploaded_note_count on public.resources; + +create trigger trg_resources_sync_uploaded_note_count +after insert or update of profile_id or delete on public.resources +for each row execute function public.fn_sync_uploaded_note_count(); diff --git a/supabase/migrations/202602191245_add_total_credits_earned_to_profiles.sql b/supabase/migrations/202602191245_add_total_credits_earned_to_profiles.sql new file mode 100644 index 0000000..2c3e9df --- /dev/null +++ b/supabase/migrations/202602191245_add_total_credits_earned_to_profiles.sql @@ -0,0 +1,49 @@ +alter table public.profiles + add column if not exists total_credits_earned bigint not null default 0 + check (total_credits_earned >= 0); + +-- Backfill using current balance + historical credits spent on downloads. +-- This gives a practical lifetime-earned baseline for existing users. +update public.profiles p +set total_credits_earned = + greatest(coalesce(p.credit_score, 0), 0)::bigint + + coalesce( + ( + select sum(greatest(rd.credits_spent, 0))::bigint + from public.resource_downloads rd + where rd.profile_id = p.id + ), + 0 + ); + +create or replace function public.fn_track_total_credits_earned() +returns trigger +language plpgsql +as $$ +declare + v_old_score integer; + v_new_score integer; + v_delta integer; +begin + if tg_op = 'INSERT' then + new.total_credits_earned := greatest( + coalesce(new.total_credits_earned, 0), + greatest(coalesce(new.credit_score, 0), 0)::bigint + ); + return new; + end if; + + v_old_score := coalesce(old.credit_score, 0); + v_new_score := coalesce(new.credit_score, 0); + v_delta := greatest(v_new_score - v_old_score, 0); + + new.total_credits_earned := coalesce(old.total_credits_earned, 0) + v_delta; + return new; +end; +$$; + +drop trigger if exists trg_profiles_track_total_credits_earned on public.profiles; + +create trigger trg_profiles_track_total_credits_earned +before insert or update of credit_score on public.profiles +for each row execute function public.fn_track_total_credits_earned();