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
121 changes: 121 additions & 0 deletions frontend/app/api/notes/[id]/favorite/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
39 changes: 38 additions & 1 deletion frontend/app/api/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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);
}
Expand Down Expand Up @@ -190,7 +215,6 @@ export async function GET(req: Request) {
const currentUserId = user.id;
const downloadedIds = new Set<string>();
if (ids.length > 0) {
const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey);
const { data: downloadsData } = await adminClient
.from("resource_downloads")
.select("resource_id")
Expand All @@ -201,6 +225,18 @@ export async function GET(req: Request) {
});
}

const favoritedIds = new Set<string>();
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) => {
Expand All @@ -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,
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/components/ThemeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 8 additions & 11 deletions frontend/app/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,26 @@
import { useTheme } from "./ThemeProvider";

export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { toggleTheme } = useTheme();

return (
<button
type="button"
onClick={toggleTheme}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900 [data-theme=dark]:border-neutral-500 [data-theme=dark]:bg-neutral-400 [data-theme=dark]:text-white [data-theme=dark]:hover:bg-neutral-500 [data-theme=dark]:hover:text-white"
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
title={theme === "dark" ? "Light mode" : "Dark mode"}
aria-label="Toggle theme"
title="Toggle theme"
>
{theme === "light" ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden>
<svg className="h-5 w-5 theme-toggle-icon-light" fill="currentColor" viewBox="0 0 20 20" aria-hidden>
<path
fillRule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clipRule="evenodd"
/>
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</svg>
<svg className="h-5 w-5 theme-toggle-icon-dark" fill="currentColor" viewBox="0 0 20 20" aria-hidden>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
</button>
);
}
43 changes: 43 additions & 0 deletions frontend/app/dashboard/course-detail.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Loading
Loading