diff --git a/frontend/app/api/classes/route.ts b/frontend/app/api/classes/route.ts index 9c7b8da..007f5eb 100644 --- a/frontend/app/api/classes/route.ts +++ b/frontend/app/api/classes/route.ts @@ -137,6 +137,7 @@ export async function GET(request: Request) { } const { searchParams } = new URL(request.url); + const classIdParam = searchParams.get("id")?.trim() || null; const limitParam = searchParams.get("limit"); const offsetParam = searchParams.get("offset"); const departmentParamRaw = searchParams.get("department")?.trim() || null; @@ -145,6 +146,34 @@ export async function GET(request: Request) { const limit = paginated ? Math.min(Math.max(1, parseInt(limitParam, 10) || PAGE_SIZE), 1000) : PAGE_SIZE; const offset = paginated ? Math.max(0, parseInt(offsetParam ?? "0", 10)) : 0; + if (classIdParam) { + const { data: course, error } = await supabase + .from("courses") + .select("id, title, department, course_number, term, year") + .eq("id", classIdParam) + .maybeSingle(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + if (!course) { + return NextResponse.json({ error: "Course not found" }, { status: 404 }); + } + + const classes = buildClasses([course as CourseRow], new Map()); + + return NextResponse.json( + { classes, hasMore: false }, + { + status: 200, + headers: { + "Cache-Control": "private, max-age=300, stale-while-revalidate=600", + }, + } + ); + } + if (paginated) { let query = supabase .from("courses") diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts index ac5b8af..05464df 100644 --- a/frontend/app/api/notes/route.ts +++ b/frontend/app/api/notes/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { createClient as createSupabaseClient } from "@supabase/supabase-js"; -import { generateSignedUrl } from "@/lib/storage"; +import { generateSignedUrls } from "@/lib/storage"; import { createClient } from "@/utils/supabaseServerClient"; type ResourceRow = { @@ -102,7 +102,6 @@ export async function GET(req: Request) { download_cost, profiles ( display_name ) `, - { count: "exact" }, ) .eq("status", "active"); // Only show active (approved) notes on dashboard @@ -125,17 +124,19 @@ export async function GET(req: Request) { query = query.eq("course_id", classId); } - query = query.range(from, to); + // Over-fetch one row to compute hasMore without expensive exact counts. + query = query.range(from, to + 1); - const { data, error, count } = await query.returns(); + const { data, error } = await query.returns(); if (error) { return NextResponse.json({ error: error.message }, { status: 500 }); } - const total = count ?? 0; - const hasMore = to + 1 < total; - const ids = data?.map((row) => row.id) ?? []; + const rows = data ?? []; + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const ids = pageRows.map((row) => row.id); let voteStats: VoteStatRow[] = []; if (ids.length > 0) { @@ -183,49 +184,52 @@ export async function GET(req: Request) { }); } - const normalized = data - ? await Promise.all( - data.map(async (row) => { - const stats = voteMap.get(row.id) ?? { upvotes: 0, downvotes: 0, score: 0 }; - const myVote = myVoteMap.get(row.id) ?? null; - const base = { - id: row.id, - title: row.title, - class_id: row.course_id, - created_at: row.created_at, - description: row.description ?? null, - storage_path: row.file_key, - profile_display_name: row.profiles?.display_name ?? null, - upvote_count: stats.upvotes, - downvote_count: stats.downvotes, - score: stats.score, - my_vote: myVote, - download_cost: row.download_cost ?? 0, - downloaded: downloadedIds.has(row.id), - }; - - const path = row.preview_key ?? row.file_key; - if (!path) { - return { ...base, previewUrl: null }; - } - - let previewUrl: string | null = null; - try { - previewUrl = await generateSignedUrl("resources", path); - } catch { - previewUrl = null; - } - return { ...base, previewUrl }; - }), - ) - : []; + const previewPaths = Array.from( + new Set( + pageRows + .map((row) => row.preview_key ?? row.file_key) + .filter((path): path is string => Boolean(path)), + ), + ); + + let previewUrlMap = new Map(); + if (previewPaths.length > 0) { + try { + previewUrlMap = await generateSignedUrls("resources", previewPaths); + } catch { + previewUrlMap = new Map(); + } + } + + const normalized = pageRows.map((row) => { + const stats = voteMap.get(row.id) ?? { upvotes: 0, downvotes: 0, score: 0 }; + const myVote = myVoteMap.get(row.id) ?? null; + const path = row.preview_key ?? row.file_key; + + return { + id: row.id, + title: row.title, + class_id: row.course_id, + created_at: row.created_at, + description: row.description ?? null, + storage_path: row.file_key, + profile_display_name: row.profiles?.display_name ?? null, + upvote_count: stats.upvotes, + downvote_count: stats.downvotes, + score: stats.score, + my_vote: myVote, + download_cost: row.download_cost ?? 0, + downloaded: downloadedIds.has(row.id), + previewUrl: path ? previewUrlMap.get(path) ?? null : null, + }; + }); return NextResponse.json( { notes: normalized, page, pageSize, - total, + total: null, hasMore, }, { 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/dashboard/course/[classId]/page.tsx b/frontend/app/dashboard/course/[classId]/page.tsx index c178c8e..865c094 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"; @@ -55,7 +55,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); @@ -115,14 +114,15 @@ export default function CourseDetailPage() { if (!tokenLoaded || !accessToken || !classId) return; let active = true; const fetchCourses = async () => { + const endpoint = `/api/classes?id=${encodeURIComponent(classId)}`; try { - let res = await fetch("/api/classes", { + let res = await fetch(endpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (res.status === 401) { const newToken = await refreshToken(); if (newToken) { - res = await fetch("/api/classes", { + res = await fetch(endpoint, { headers: { Authorization: `Bearer ${newToken}` }, }); } @@ -134,8 +134,7 @@ export default function CourseDetailPage() { return; } const data = (await res.json()) as { classes?: CourseOption[] }; - const list = data.classes ?? []; - const found = list.find((c) => c.id === classId) ?? null; + const found = data.classes?.[0] ?? null; setCourse(found); setCoursesError(found ? null : "Course not found"); } catch { @@ -449,7 +448,7 @@ export default function CourseDetailPage() { setVotingId(null); } }, - [accessToken, votingId, refreshToken, fetchCredits, selectedNote], + [accessToken, votingId, refreshToken, fetchCredits], ); const handleOpenNoteModal = (note: Note) => { @@ -545,6 +544,9 @@ export default function CourseDetailPage() {
Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes @@ -577,6 +579,9 @@ export default function CourseDetailPage() {
Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes @@ -586,157 +591,158 @@ export default function CourseDetailPage() {
- {course && ( - <> -
-
-
- - {termYearLabel(course.term, course.year)} - -
-

{course.code ?? course.name}

- {(() => { - const subline = getCourseSubline(course.code); - return subline ?

{subline}

: null; - })()} + <> +
+
+
+ + {course ? termYearLabel(course.term, course.year) : "Loading course…"} +
+

{course ? (course.code ?? course.name) : "Loading course…"}

+ {(() => { + if (!course) return null; + const subline = getCourseSubline(course.code); + return subline ?

{subline}

: null; + })()} +
+ {course ? ( Add Note -
+ ) : null} +
-
- - + + +
+ +
+
+ Filter: + -
- -
-
- Filter: - - -
-
- Sort: - - -
- - {filteredNotes.length} notes available - +
+ Sort: + +
+ + {filteredNotes.length} notes available + +
- {downloadError && ( -

{downloadError}

- )} - {voteError && ( -

- {voteError} -

- )} - {notesError && ( -

{notesError}

- )} - -
- {filteredNotes.map((note) => ( - - ))} -
+ {downloadError && ( +

{downloadError}

+ )} + {voteError && ( +

+ {voteError} +

+ )} + {notesError && ( +

{notesError}

+ )} - {loadingNotes && ( -

Loading notes…

- )} - {!loadingNotes && hasMore && ( +
+ {filteredNotes.map((note) => ( + ))} +
+ + {loadingNotes && ( +

Loading notes…

+ )} + {!loadingNotes && hasMore && ( + + )} + {!loadingNotes && + filteredNotes.length === 0 && + !notesError && ( +

No notes in this course yet.

)} - {!loadingNotes && - filteredNotes.length === 0 && - !notesError && ( -

No notes in this course yet.

- )} - - )} +
{/* Note preview modal – download only from modal */} diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index e0c4ee4..a796feb 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -38,13 +38,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); @@ -57,7 +50,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). */ @@ -126,22 +119,18 @@ 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); @@ -171,7 +160,10 @@ export default function DashboardPage() { } }; run(); - return () => { active = false; }; + return () => { + active = false; + window.clearTimeout(loadingTimer); + }; }, [accessToken, tokenLoaded, selectedDepartment, refreshToken, fetchCoursesPage]); useEffect(() => { @@ -275,7 +267,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); @@ -294,11 +289,12 @@ 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) { @@ -346,6 +342,9 @@ export default function DashboardPage() { Credits: {credits ?? "—"} + + Free downloads: {freeDownloads ?? "—"} + Upload Notes diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx index 6eb6f94..1ce6e3b 100644 --- a/frontend/app/upload/page.tsx +++ b/frontend/app/upload/page.tsx @@ -45,7 +45,6 @@ export default function UploadPage() { const [classesLoading, setClassesLoading] = useState(false); const [tokenLoaded, setTokenLoaded] = useState(false); - const [isAuthenticated, setIsAuthenticated] = useState(false); const router = useRouter(); const [file, setFile] = useState(null); @@ -70,7 +69,6 @@ export default function UploadPage() { router.replace("/auth"); return; } - setIsAuthenticated(true); })(); }, [router]); @@ -475,7 +473,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 && ( @@ -494,6 +491,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(); @@ -523,6 +521,11 @@ export default function UploadPage() { {classNotFoundError}

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

+ {classesError} +

+ )} { export const SIGNED_URL_TTL_SECONDS = DEFAULT_SIGNED_URL_TTL_SECONDS; +export async function generateSignedUrls( + bucket: string, + filePaths: string[], + expiresIn: number = DEFAULT_SIGNED_URL_TTL_SECONDS, +): Promise> { + if (!bucket) { + throw new Error("Bucket is required to generate signed URLs."); + } + + const paths = filePaths.filter((path) => Boolean(path)); + if (paths.length === 0) return new Map(); + + const { data, error } = await getAdminClient() + .storage + .from(bucket) + .createSignedUrls(paths, expiresIn); + + if (error || !data) { + throw new Error(`Failed to generate signed URLs: ${error?.message ?? "Unknown error"}`); + } + + const signedUrlMap = new Map(); + data.forEach((entry) => { + if (entry?.path && entry?.signedUrl) { + signedUrlMap.set(entry.path, entry.signedUrl); + } + }); + + return signedUrlMap; +} + export async function generateSignedUrl( bucket: string, filePath: string, diff --git a/frontend/scripts/build-calpoly-catalog.mjs b/frontend/scripts/build-calpoly-catalog.mjs index 3ed81ec..cd79cb1 100644 --- a/frontend/scripts/build-calpoly-catalog.mjs +++ b/frontend/scripts/build-calpoly-catalog.mjs @@ -14,7 +14,7 @@ const DEPT_SLUGS = [ // Use 4 digits to avoid matching "Formerly DEPT 121" etc. in body text. const COURSE_REGEX = /([A-Z]{2,5})\s+(\d{4}[A-Z]?)(?:\s+)?(.+?)\s*\(\d+(?:-\d+)?\s*units?\)/g; -function parseCoursesFromText(text, departmentHint) { +function parseCoursesFromText(text) { const courses = []; let m; COURSE_REGEX.lastIndex = 0; @@ -56,7 +56,7 @@ async function run() { try { const html = await fetchPage(url); const text = html.replace(/<[^>]+>/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"); }