Skip to content
Merged
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
75 changes: 75 additions & 0 deletions frontend/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -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<LeaderboardRow[]>();

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 });
}
2 changes: 1 addition & 1 deletion frontend/app/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function AuthPage() {
};

checkSession();
}, [router]);
}, [router, redirectTo]);

const content = checking ? (
<p className="auth-loading">Checking your session…</p>
Expand Down
12 changes: 3 additions & 9 deletions frontend/app/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,11 @@ type ThemeContextValue = {
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
const [theme, setThemeState] = useState<Theme>(() => getStoredTheme());

useEffect(() => {
const stored = getStoredTheme();
setThemeState(stored);
applyTheme(stored);
setMounted(true);
}, []);
applyTheme(theme);
}, [theme]);

const setTheme = useCallback((next: Theme) => {
setThemeState(next);
Expand All @@ -45,8 +41,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
setTheme(theme === "light" ? "dark" : "light");
}, [theme, setTheme]);

if (!mounted) return <>{children}</>;

return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
Expand Down
21 changes: 12 additions & 9 deletions frontend/app/dashboard/course/[classId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<CourseOption | null>(null);
Expand Down Expand Up @@ -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 {
Expand All @@ -357,7 +355,6 @@ export default function CourseDetailPage() {
downloadingId,
refreshToken,
fetchCredits,
selectedNote?.id,
],
);

Expand Down Expand Up @@ -450,7 +447,7 @@ export default function CourseDetailPage() {
setVotingId(null);
}
},
[accessToken, votingId, refreshToken, fetchCredits, selectedNote],
[accessToken, votingId, refreshToken, fetchCredits],
);

const handleOpenNoteModal = (note: Note) => {
Expand Down Expand Up @@ -536,6 +533,9 @@ export default function CourseDetailPage() {
rightSlot={
<>
<span className="browse-credits-pill">Credits: {credits ?? "—"}</span>
<span className="browse-credits-pill">
Free downloads: {freeDownloads ?? "—"}
</span>
<Link href="/upload" className="browse-upload-btn">Upload Notes</Link>
<ProfileIcons />
</>
Expand All @@ -556,6 +556,9 @@ export default function CourseDetailPage() {
rightSlot={
<>
<span className="browse-credits-pill">Credits: {credits ?? "—"}</span>
<span className="browse-credits-pill">
Free downloads: {freeDownloads ?? "—"}
</span>
<Link href="/upload" className="browse-upload-btn">Upload Notes</Link>
<ProfileIcons />
</>
Expand Down
65 changes: 35 additions & 30 deletions frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CourseOption[]>([]);
const [selectedDepartment, setSelectedDepartment] = useState<string | null>(null);
Expand All @@ -58,15 +51,15 @@ export default function DashboardPage() {
const [coursesLoading, setCoursesLoading] = useState(false);
const [coursesLoadingMore, setCoursesLoadingMore] = useState(false);
const [catalogTerms, setCatalogTerms] = useState<CatalogTerm[]>(FALLBACK_CATALOG_TERMS);
const [departments, setDepartments] = useState<string[]>(() => [...CALPOLY_DEPARTMENT_CODES]);
const [departments] = useState<string[]>(() => [...CALPOLY_DEPARTMENT_CODES]);
const [credits, setCredits] = useState<number | null>(null);
const [freeDownloads, setFreeDownloads] = useState<number | null>(null);
/** Number of course cards to render (paginated for performance). */
const [visibleCourseCount, setVisibleCourseCount] = useState(80);
/** 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);
Expand All @@ -76,10 +69,6 @@ export default function DashboardPage() {
return newToken;
}, []);

useEffect(() => {
setIsVisible(true);
}, []);

useEffect(() => {
const loadSession = async () => {
const { session, error } = await getSessionWithRecovery(supabase);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -178,7 +168,10 @@ export default function DashboardPage() {
}
};
run();
return () => { active = false; };
return () => {
active = false;
window.clearTimeout(loadingTimer);
};
}, [accessToken, tokenLoaded, selectedDepartment, refreshToken, fetchCoursesPage]);

useEffect(() => {
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -341,6 +343,9 @@ export default function DashboardPage() {
rightSlot={
<>
<span className="browse-credits-pill">Credits: {credits ?? "—"}</span>
<span className="browse-credits-pill">
Free downloads: {freeDownloads ?? "—"}
</span>
<Link href="/upload" className="browse-upload-btn">Upload Notes</Link>
<ProfileIcons />
</>
Expand Down
Loading