diff --git a/frontend/app/api/notes/[id]/favorite/route.ts b/frontend/app/api/notes/[id]/favorite/route.ts new file mode 100644 index 0000000..8bdc8cc --- /dev/null +++ b/frontend/app/api/notes/[id]/favorite/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { createClient as createSupabaseClient } from "@supabase/supabase-js"; +import { createClient } from "@/utils/supabaseServerClient"; + +async function getAuthenticatedUser() { + 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 || !user) { + const status = + userError?.message === "Auth session missing!" || !user + ? 401 + : 500; + return { + ok: false as const, + response: NextResponse.json( + { error: userError?.message ?? "Not authenticated" }, + { status }, + ), + }; + } + + return { ok: true as const, user }; +} + +function getAdminClient() { + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; + const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!supabaseUrl || !supabaseServiceRoleKey) { + return null; + } + return createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); +} + +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const auth = await getAuthenticatedUser(); + if (!auth.ok) return auth.response; + + const adminClient = getAdminClient(); + if (!adminClient) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + // Keep exactly one favorite row per user/resource, even without a unique constraint. + const { error: clearError } = await adminClient + .from("resource_favorites") + .delete() + .eq("profile_id", auth.user.id) + .eq("resource_id", resourceId); + + if (clearError) { + return NextResponse.json({ error: clearError.message }, { status: 500 }); + } + + const { error: insertError } = await adminClient + .from("resource_favorites") + .insert({ + profile_id: auth.user.id, + resource_id: resourceId, + }); + + if (insertError) { + return NextResponse.json({ error: insertError.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true, favorited: true }, { status: 200 }); +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: resourceId } = await params; + if (!resourceId) { + return NextResponse.json({ error: "Resource id is required." }, { status: 400 }); + } + + const auth = await getAuthenticatedUser(); + if (!auth.ok) return auth.response; + + const adminClient = getAdminClient(); + if (!adminClient) { + return NextResponse.json( + { error: "Supabase environment variables are not configured." }, + { status: 500 }, + ); + } + + const { error } = await adminClient + .from("resource_favorites") + .delete() + .eq("profile_id", auth.user.id) + .eq("resource_id", resourceId); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } + + return NextResponse.json({ ok: true, favorited: false }, { status: 200 }); +} diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts index 625c6a0..8cdb25f 100644 --- a/frontend/app/api/notes/route.ts +++ b/frontend/app/api/notes/route.ts @@ -71,6 +71,7 @@ export async function GET(req: Request) { const searchQuery = searchParams.get("search"); const mine = searchParams.get("mine") === "1"; const downloaded = searchParams.get("downloaded") === "1"; + const favorited = searchParams.get("favorited") === "1"; const resourceTypeParam = searchParams.get("resource_type"); const resourceType = resourceTypeParam && RESOURCE_TYPE_FILTERS.has(resourceTypeParam) @@ -83,6 +84,8 @@ export async function GET(req: Request) { const from = (page - 1) * pageSize; const to = from + pageSize - 1; + const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); + // When "downloaded=1", fetch resource_ids from resource_downloads first, then query resources. let downloadedResourceIds: string[] = []; if (downloaded) { @@ -99,6 +102,25 @@ export async function GET(req: Request) { } } + // When "favorited=1", fetch resource_ids from resource_favorites first, then query resources. + let favoritedResourceIds: string[] = []; + if (favorited) { + const { data: favoritesData, error: favoritesError } = await adminClient + .from("resource_favorites") + .select("resource_id") + .eq("profile_id", user.id); + if (favoritesError) { + return NextResponse.json({ error: favoritesError.message }, { status: 500 }); + } + favoritedResourceIds = (favoritesData ?? []).map((d: { resource_id: string }) => d.resource_id); + if (favoritedResourceIds.length === 0) { + return NextResponse.json( + { notes: [], page, pageSize, total: 0, hasMore: false }, + { status: 200, headers: { "Cache-Control": "no-store" } }, + ); + } + } + let query = supabase .from("resources") .select( @@ -123,6 +145,9 @@ export async function GET(req: Request) { if (downloaded) { query = query.in("id", downloadedResourceIds); } + if (favorited) { + query = query.in("id", favoritedResourceIds); + } if (resourceType) { query = query.eq("resource_type", resourceType); } @@ -190,7 +215,6 @@ export async function GET(req: Request) { const currentUserId = user.id; const downloadedIds = new Set(); if (ids.length > 0) { - const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey); const { data: downloadsData } = await adminClient .from("resource_downloads") .select("resource_id") @@ -201,6 +225,18 @@ export async function GET(req: Request) { }); } + const favoritedIds = new Set(); + if (ids.length > 0) { + const { data: favoritesData } = await adminClient + .from("resource_favorites") + .select("resource_id") + .eq("profile_id", currentUserId) + .in("resource_id", ids); + (favoritesData ?? []).forEach((d: { resource_id: string }) => { + favoritedIds.add(d.resource_id); + }); + } + // Preview URLs go through our backend proxy (GET /api/notes/[id]/preview) so no signed // storage URL is ever sent to the client; auth is required and inspect cannot get a shareable link. const normalized = pageRows.map((row) => { @@ -226,6 +262,7 @@ export async function GET(req: Request) { my_vote: myVote, download_cost: row.download_cost ?? 0, downloaded: downloadedIds.has(row.id), + favorited: favoritedIds.has(row.id), previewUrl, previewIsPdf, }; diff --git a/frontend/app/components/ThemeProvider.tsx b/frontend/app/components/ThemeProvider.tsx index dcc18eb..4c621f5 100644 --- a/frontend/app/components/ThemeProvider.tsx +++ b/frontend/app/components/ThemeProvider.tsx @@ -9,7 +9,7 @@ function getStoredTheme(): Theme { if (typeof window === "undefined") return "light"; const stored = localStorage.getItem(THEME_KEY) as Theme | null; if (stored === "dark" || stored === "light") return stored; - return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function applyTheme(theme: Theme) { diff --git a/frontend/app/components/ThemeToggle.tsx b/frontend/app/components/ThemeToggle.tsx index 2d21a98..c8676c3 100644 --- a/frontend/app/components/ThemeToggle.tsx +++ b/frontend/app/components/ThemeToggle.tsx @@ -3,29 +3,26 @@ import { useTheme } from "./ThemeProvider"; export function ThemeToggle() { - const { theme, toggleTheme } = useTheme(); + const { toggleTheme } = useTheme(); return ( ); } diff --git a/frontend/app/dashboard/course-detail.css b/frontend/app/dashboard/course-detail.css index 79f88aa..5856346 100644 --- a/frontend/app/dashboard/course-detail.css +++ b/frontend/app/dashboard/course-detail.css @@ -194,6 +194,18 @@ border-color: var(--poly-sage); } +.course-detail-note-card:focus-visible { + outline: 2px solid var(--poly-sage); + outline-offset: 2px; +} + +.course-detail-note-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + .course-detail-note-title { font-size: 16px; font-weight: 600; @@ -206,6 +218,37 @@ overflow: hidden; } +.course-detail-note-star { + border: 1px solid rgba(229, 231, 235, 1); + border-radius: 8px; + width: 32px; + height: 32px; + flex-shrink: 0; + background: #fff; + color: var(--poly-neutral-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + transition: border-color 0.2s, color 0.2s, background 0.2s; +} + +.course-detail-note-star:hover:not(:disabled) { + border-color: var(--poly-sage); + color: var(--poly-sage); + background: var(--poly-sage-soft); +} + +.course-detail-note-star.is-active { + border-color: var(--poly-sage); + color: var(--poly-sage); + background: var(--poly-sage-soft); +} + +.course-detail-note-star:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .course-detail-note-by { font-size: 14px; font-weight: 400; diff --git a/frontend/app/dashboard/course/[classId]/page.tsx b/frontend/app/dashboard/course/[classId]/page.tsx index 7b07e5e..db4fd4c 100644 --- a/frontend/app/dashboard/course/[classId]/page.tsx +++ b/frontend/app/dashboard/course/[classId]/page.tsx @@ -52,6 +52,7 @@ type Note = { my_vote: number | null; download_cost: number; downloaded: boolean; + favorited: boolean; }; const RESOURCE_TYPE_FILTER_OPTIONS: { value: string | null; label: string }[] = [ @@ -100,6 +101,7 @@ function CourseDetailPage() { const [downloadError, setDownloadError] = useState(null); const [downloadingId, setDownloadingId] = useState(null); const [selectedNote, setSelectedNote] = useState(null); + const [favoriteSavingId, setFavoriteSavingId] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isReportOpen, setIsReportOpen] = useState(false); const [reportReason, setReportReason] = useState(""); @@ -688,6 +690,58 @@ function CourseDetailPage() { setIsReportOpen(true); }; + const handleToggleStar = useCallback(async (noteId: string) => { + if (!accessToken || favoriteSavingId === noteId) return; + const targetNote = + notes.find((note) => note.id === noteId) ?? + (selectedNote?.id === noteId ? selectedNote : null); + if (!targetNote) return; + const prevFavorited = Boolean(targetNote.favorited); + const nextFavorited = !Boolean(targetNote.favorited); + + // Optimistic UI: flip star immediately for snappy feedback. + setNotes((prev) => + prev.map((note) => + note.id === noteId ? { ...note, favorited: nextFavorited } : note + ) + ); + setSelectedNote((prev) => + prev && prev.id === noteId ? { ...prev, favorited: nextFavorited } : prev + ); + + setFavoriteSavingId(noteId); + try { + let res = await fetch(`/api/notes/${noteId}/favorite`, { + method: nextFavorited ? "POST" : "DELETE", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (res.status === 401) { + const newToken = await refreshToken(); + if (newToken) { + res = await fetch(`/api/notes/${noteId}/favorite`, { + method: nextFavorited ? "POST" : "DELETE", + headers: { Authorization: `Bearer ${newToken}` }, + }); + } + } + if (!res.ok) { + throw new Error("Failed to save favorite."); + } + } catch { + // Revert optimistic update if server call fails. + setNotes((prev) => + prev.map((note) => + note.id === noteId ? { ...note, favorited: prevFavorited } : note + ) + ); + setSelectedNote((prev) => + prev && prev.id === noteId ? { ...prev, favorited: prevFavorited } : prev + ); + } finally { + setFavoriteSavingId(null); + } + }, [notes, selectedNote, accessToken, favoriteSavingId, refreshToken]); + const handleCloseReport = () => { setIsReportOpen(false); setReportReason(""); @@ -734,6 +788,10 @@ function CourseDetailPage() { if (hasMore && !loadingNotes) setPage((p) => p + 1); }; + const selectedNoteIsStarred = Boolean(selectedNote?.favorited); + const selectedNoteFavoriteSaving = + selectedNote != null && favoriteSavingId === selectedNote.id; + if (!classId) { return (
@@ -928,13 +986,35 @@ function CourseDetailPage() {
{filteredNotes.map((note) => ( - +

by {note.profile_display_name ?? "Anonymous"}

@@ -951,7 +1031,7 @@ function CourseDetailPage() { {note.downloaded ? "πŸ”“" : "πŸ”’"} {note.downloaded ? "Owned" : `βˆ’${note.download_cost} credits`}
- + ))} @@ -1229,13 +1309,28 @@ function CourseDetailPage() { Downvote - +
+ + +
diff --git a/frontend/app/dashboard/profile-dashboard/page.tsx b/frontend/app/dashboard/profile-dashboard/page.tsx index 4bbb63b..f46c38a 100644 --- a/frontend/app/dashboard/profile-dashboard/page.tsx +++ b/frontend/app/dashboard/profile-dashboard/page.tsx @@ -55,8 +55,11 @@ export default function Page() { const [activeTab, setActiveTab] = useState<"uploads" | "downloads" | "favorites">("uploads"); const [uploads, setUploads] = useState([]); const [downloads, setDownloads] = useState([]); + const [favorites, setFavorites] = useState([]); const [loadingUploads, setLoadingUploads] = useState(false); const [loadingDownloads, setLoadingDownloads] = useState(false); + const [loadingFavorites, setLoadingFavorites] = useState(false); + const [favoritesLoaded, setFavoritesLoaded] = useState(false); const [notesError, setNotesError] = useState(null); const [profileStats, setProfileStats] = useState(null); const [leaderboardRank, setLeaderboardRank] = useState(null); @@ -183,6 +186,31 @@ export default function Page() { } }, [accessToken]); + const fetchFavorites = useCallback(async () => { + if (!accessToken) return; + setLoadingFavorites(true); + setNotesError(null); + try { + const res = await fetch("/api/notes?favorited=1&page_size=50", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + const payload = (await res.json().catch(() => ({}))) as { error?: string }; + setNotesError(payload.error ?? "Failed to load favorites"); + setFavorites([]); + return; + } + const data = (await res.json()) as { notes?: ProfileNote[] }; + setFavorites(data.notes ?? []); + setFavoritesLoaded(true); + } catch { + setNotesError("Failed to load favorites"); + setFavorites([]); + } finally { + setLoadingFavorites(false); + } + }, [accessToken]); + useEffect(() => { if (!tokenLoaded || !accessToken) return; void fetchUploads(); @@ -193,6 +221,11 @@ export default function Page() { void fetchDownloads(); }, [tokenLoaded, accessToken, fetchDownloads]); + useEffect(() => { + if (!tokenLoaded || !accessToken || activeTab !== "favorites" || favoritesLoaded) return; + void fetchFavorites(); + }, [tokenLoaded, accessToken, activeTab, favoritesLoaded, fetchFavorites]); + const fetchProfileStats = useCallback(async () => { if (!accessToken) return; setLoadingStats(true); @@ -394,7 +427,40 @@ export default function Page() { )} {activeTab === "favorites" && ( -

No favorites yet. Favorites coming soon.

+ <> + {loadingFavorites &&

Loading your favorites…

} + {!loadingFavorites && favorites.length === 0 && ( +

No favorites yet. Star notes to save them here.

+ )} + {!loadingFavorites && favorites.length > 0 && ( +
    + {favorites.map((note) => ( +
  • + +

    {note.title}

    +

    + by {note.profile_display_name ?? "Anonymous"} +

    +
    +
    + ↑ {note.upvote_count} + ↓ {note.downvote_count} +
    + β˜… Favorite +
    + +
  • + ))} +
+ )} + )} @@ -450,7 +516,9 @@ export default function Page() {

+ {theme === "dark" ? "Dark mode" : "Light mode"} +

diff --git a/frontend/app/globals.css b/frontend/app/globals.css index a1b95c7..374129e 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -382,12 +382,63 @@ select { background: rgba(248, 113, 113, 0.1) !important; } +[data-theme="dark"] .note-modal-star-btn { + background: transparent !important; + border-color: rgba(250, 204, 21, 0.45); + color: #facc15 !important; +} + +[data-theme="dark"] .note-modal-star-btn:hover { + color: #fde68a !important; + background: rgba(250, 204, 21, 0.18) !important; +} + +[data-theme="dark"] .note-modal-star-btn.is-active { + color: #fef08a !important; + background: rgba(250, 204, 21, 0.26) !important; + border-color: rgba(250, 204, 21, 0.72); +} + +.theme-toggle-icon-dark { + display: none; +} + +[data-theme="dark"] .theme-toggle-icon-light { + display: none; +} + +[data-theme="dark"] .theme-toggle-icon-dark { + display: block; +} + [data-theme="dark"] .course-detail-note-title, [data-theme="dark"] .course-detail-note-by, [data-theme="dark"] .profile-page__note-card { color: #e5e5e5 !important; } +[data-theme="dark"] .course-detail-note-star { + background: #1f2937; + border-color: rgba(250, 204, 21, 0.45); + color: #facc15; +} + +[data-theme="dark"] .course-detail-note-star:hover:not(:disabled) { + background: rgba(250, 204, 21, 0.18); + border-color: rgba(250, 204, 21, 0.72); + color: #fef08a; +} + +[data-theme="dark"] .course-detail-note-star.is-active { + background: rgba(250, 204, 21, 0.24); + border-color: rgba(250, 204, 21, 0.72); + color: #fef08a; +} + +[data-theme="dark"] .course-detail-note-star:disabled { + opacity: 0.55; +} + /* Landing / poly in dark */ [data-theme="dark"] .poly-navbar, [data-theme="dark"] .poly-hero-title, @@ -1566,6 +1617,13 @@ body { flex-shrink: 0; } +.note-modal-actions-secondary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} + .note-modal-report-btn { border-radius: 8px; @@ -1584,3 +1642,31 @@ body { color: #b91c1c; background: rgba(185, 28, 28, 0.06); } + +.note-modal-star-btn { + width: 38px; + height: 38px; + border-radius: 8px; + border: 1px solid rgba(245, 158, 11, 0.42); + background: transparent; + color: #f59e0b; + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.25rem; + line-height: 1; + flex-shrink: 0; +} + +.note-modal-star-btn:hover { + color: #d97706; + background: rgba(245, 158, 11, 0.16); +} + +.note-modal-star-btn.is-active { + color: #ca8a04; + background: rgba(250, 204, 21, 0.18); + border-color: rgba(245, 158, 11, 0.65); +}