diff --git a/docs/TEXTEXTRACTOR_SETUP.md b/docs/TEXTEXTRACTOR_SETUP.md
new file mode 100644
index 0000000..a751bd7
--- /dev/null
+++ b/docs/TEXTEXTRACTOR_SETUP.md
@@ -0,0 +1,159 @@
+# TEXTEXTRACTOR — Full setup and usage guide
+
+This guide walks you through the **entire** TEXTEXTRACTOR flow: what it does, how to use the API, how it’s built, and optional external services for better handwriting support.
+
+---
+
+## What it does
+
+- **Typed/digital PDFs**
+ Extracts text from the PDF’s built-in text layer (no OCR). Fast and accurate.
+
+- **Scanned / handwritten PDFs**
+ When there’s little or no text in the file, the pipeline falls back to **OCR**:
+ 1. Renders PDF pages to images (using `pdf2pic`).
+ 2. Runs **Tesseract.js** on each image to recognize text (including handwriting, with varying quality).
+
+So one API handles both: you send a PDF and get back plain text, with an indication of whether it came from the text layer or OCR.
+
+---
+
+## Using the API
+
+### Endpoint
+
+- **URL:** `POST /api/textextractor`
+- **Auth:** None required for this route (add auth in your app if you want).
+- **Body:** `multipart/form-data` with a PDF file.
+
+### Form fields
+
+| Field | Required | Description |
+|------------------|----------|-------------|
+| `file` or `pdf` | Yes | The PDF file. |
+| `force_ocr` | No | If `"true"` or `"1"`, skip typed extraction and run OCR only (useful for known scanned docs). |
+| `max_ocr_pages` | No | Max number of pages to run OCR on (default 10, cap 50). |
+
+### Example (curl)
+
+```bash
+curl -X POST http://localhost:3000/api/textextractor \
+ -F "file=@/path/to/notes.pdf"
+```
+
+Force OCR for all (e.g. scanned) PDFs:
+
+```bash
+curl -X POST http://localhost:3000/api/textextractor \
+ -F "file=@/path/to/scan.pdf" \
+ -F "force_ocr=true"
+```
+
+### Example (JavaScript)
+
+```javascript
+const form = new FormData();
+form.append("file", pdfFile); // File from or fetch
+
+const res = await fetch("/api/textextractor", {
+ method: "POST",
+ body: form,
+});
+const data = await res.json();
+
+if (!res.ok) {
+ console.error(data.error, data.details);
+ return;
+}
+
+console.log("Method:", data.method); // "typed" or "ocr"
+console.log("Text:", data.text);
+console.log("Pages processed:", data.pages_processed);
+console.log("Total pages:", data.total_pages);
+if (data.ocr_confidence != null) {
+ console.log("OCR confidence:", data.ocr_confidence);
+}
+```
+
+### Response shape
+
+- **Success (200):**
+ - `text` (string) — Extracted text.
+ - `method` (`"typed"` | `"ocr"`) — How the text was obtained.
+ - `pages_processed` (number) — Pages used for extraction/OCR.
+ - `total_pages` (number) — Total pages in the PDF.
+ - `ocr_confidence` (number, optional) — Only when `method === "ocr"`; average confidence from Tesseract (0–100).
+
+- **Error (4xx/5xx):**
+ - `error` (string) — Short message.
+ - `details` (string, optional) — Extra info (e.g. stack or internal message).
+
+---
+
+## How it’s built (in this repo)
+
+1. **Dependencies**
+ - **pdfjs-dist** — Reads the text layer from a PDF buffer (typed PDFs); already in the project.
+ - **pdf2pic** — Renders PDF pages to PNG/JPEG (already used for previews).
+ - **tesseract.js** — OCR on images (works in Node; no separate Tesseract install).
+
+2. **Library: `frontend/lib/textextractor.ts`**
+ - `extractTyped(buffer)` — Uses `pdfjs-dist` to get text and page count.
+ - `extractOcr(pdfPath, maxPages)` — Uses `pdf2pic` + Tesseract.js to OCR pages (writes PDF to a temp file because `pdf2pic` needs a path).
+ - `extract(buffer, options)` — Tries typed first; if the average text per page is below a threshold (default 20 chars), runs OCR. Options: `maxOcrPages`, `minCharsPerPage`, `forceOcr`.
+
+3. **API route: `frontend/app/api/textextractor/route.ts`**
+ - Accepts `multipart/form-data` with `file` or `pdf`.
+ - Validates type (PDF) and size (max 25 MB).
+ - Calls `extract()` and returns the JSON above.
+
+No external API keys are required for the built-in flow.
+
+---
+
+## Optional: external APIs for better handwriting
+
+The built-in pipeline uses **Tesseract.js** for OCR. It’s free and runs locally but handwriting quality can be mixed. If you need better results on handwritten notes, you can:
+
+1. **Keep the current API** for typed PDFs and “good enough” handwritten/scanned text.
+2. **Add an optional external OCR step** (e.g. only when `force_ocr=true` or when Tesseract confidence is low) that calls one of the services below and then merge or replace the text.
+
+### Free / freemium options
+
+| Service | Free tier / notes |
+|----------------------|--------------------|
+| **Google Cloud Vision** | $300 free credit; Document AI has document + handwriting models. |
+| **OCR.space** | 25,000 requests/month free; Engine 3 supports handwriting; API key required. |
+| **OCRAPI.cloud** | 250 requests/month free; supports handwritten + typed. |
+| **Azure Document Intelligence** | Free tier (e.g. 500 pages/month); good for documents and handwriting. |
+
+### Wiring an external OCR provider
+
+1. Add an env var for the API key (e.g. `OCR_API_KEY`, `GOOGLE_VISION_KEY`).
+2. In `lib/textextractor.ts` (or a separate `lib/ocr-external.ts`):
+ - When you want to use the external API (e.g. `forceOcr` and key is set), render pages with `pdf2pic`, then send each image (or a multi-page PDF) to the provider’s REST API.
+ - Map their response to your existing `ExtractResult` shape (e.g. set `method: "ocr"`, `text`, `pagesProcessed`, optional `ocrConfidence`).
+3. In the API route, pass a flag or option so the extractor can choose “internal OCR vs external OCR” (e.g. query param `ocr=external` or env-based).
+
+This way you keep one **TEXTEXTRACTOR** API; only the backend implementation of “OCR path” changes when the key is set.
+
+---
+
+## Limits and tips
+
+- **Size:** Max 25 MB per PDF (configurable in `route.ts`).
+- **OCR pages:** Default max 10 pages for OCR; use `max_ocr_pages` to change (capped at 50) to balance speed and cost if you later add a paid API.
+- **Handwriting:** Tesseract.js is best on clear, printed text; handwriting support is limited. For heavy handwriting, use an external API as above.
+- **Performance:** Typed extraction is quick; OCR is slower (especially first run while Tesseract.js loads language data). Consider a “processing” status in the UI for large or OCR-only requests.
+
+---
+
+## Quick checklist
+
+- [ ] Run `npm install` in `frontend` (adds `tesseract.js`; `pdfjs-dist` and `pdf2pic` are already present).
+- [ ] Start dev server: `npm run dev` in `frontend`.
+- [ ] Call `POST /api/textextractor` with a PDF in `file` or `pdf`.
+- [ ] (Optional) Add auth to the route if the app is not public.
+- [ ] (Optional) Integrate an external OCR API for better handwriting and set env keys.
+
+You now have a single API that analyzes text from both typed and handwritten/typed PDF notes, with the option to plug in a stronger OCR service later.
diff --git a/frontend/app/api/admin/approve-latest-resource/route.ts b/frontend/app/api/admin/approve-latest-resource/route.ts
new file mode 100644
index 0000000..cbf808f
--- /dev/null
+++ b/frontend/app/api/admin/approve-latest-resource/route.ts
@@ -0,0 +1,101 @@
+import { NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { createClient } from "@supabase/supabase-js";
+import { createClient as createServerClient } from "@/utils/supabaseServerClient";
+
+type ResourceRow = {
+ id: string;
+ title: string;
+ status: string;
+};
+
+export async function POST() {
+ const headerStore = await headers();
+ const authHeader = headerStore.get("authorization");
+ const bearerToken = authHeader?.toLowerCase().startsWith("bearer ")
+ ? authHeader.split(" ")[1]?.trim()
+ : null;
+
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+
+ if (!supabaseUrl || !supabaseServiceRoleKey) {
+ return NextResponse.json(
+ { error: "Supabase environment variables are not configured." },
+ { status: 500 },
+ );
+ }
+
+ const supabase = await createServerClient(bearerToken);
+ const {
+ data: { user },
+ error: userError,
+ } = await supabase.auth.getUser();
+
+ if (userError || !user) {
+ return NextResponse.json({ error: "Not authenticated." }, { status: 401 });
+ }
+
+ const adminClient = createClient(supabaseUrl, supabaseServiceRoleKey);
+
+ const { data: roles } = await adminClient
+ .from("user_roles")
+ .select("role")
+ .eq("profile_id", user.id)
+ .in("role", ["admin", "moderator", "developer"]);
+
+ if (!roles || roles.length === 0) {
+ return NextResponse.json(
+ { error: "Only admins or moderators can approve notes." },
+ { status: 403 },
+ );
+ }
+
+ // Find the most recently created pending resource and approve it.
+ const { data: rows, error: listError } = await adminClient
+ .from("resources")
+ .select("id, title, status")
+ .eq("status", "pending")
+ .order("created_at", { ascending: false })
+ .limit(1)
+ .returns();
+
+ if (listError) {
+ return NextResponse.json(
+ { error: "Failed to list resources.", details: listError.message },
+ { status: 500 },
+ );
+ }
+
+ const resource = rows?.[0];
+ if (!resource) {
+ return NextResponse.json(
+ { ok: false, message: "No pending resources to approve." },
+ { status: 200 },
+ );
+ }
+
+ const { error: updateError } = await adminClient
+ .from("resources")
+ .update({ status: "active" })
+ .eq("id", resource.id);
+
+ if (updateError) {
+ return NextResponse.json(
+ { error: "Failed to approve resource.", details: updateError.message },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json(
+ {
+ ok: true,
+ id: resource.id,
+ title: resource.title,
+ previousStatus: resource.status,
+ newStatus: "active",
+ },
+ { status: 200 },
+ );
+}
+
diff --git a/frontend/app/api/catalog/default-catalog-terms.ts b/frontend/app/api/catalog/default-catalog-terms.ts
new file mode 100644
index 0000000..25849ac
--- /dev/null
+++ b/frontend/app/api/catalog/default-catalog-terms.ts
@@ -0,0 +1,21 @@
+/**
+ * Default catalog terms (2026-2028). Used when catalog_terms table is empty or unavailable.
+ * Single source of truth for the backend; DB table overrides when populated.
+ */
+export type CatalogTermItem = {
+ id: string;
+ label: string;
+ term: string;
+ year: number;
+};
+
+export const DEFAULT_CATALOG_TERMS: CatalogTermItem[] = [
+ { id: "default-fall-2026", label: "Fall 2026", term: "Fall", year: 2026 },
+ { id: "default-winter-2027", label: "Winter 2027", term: "Winter", year: 2027 },
+ { id: "default-spring-2027", label: "Spring 2027", term: "Spring", year: 2027 },
+ { id: "default-summer-2027", label: "Summer 2027", term: "Summer", year: 2027 },
+ { id: "default-fall-2027", label: "Fall 2027", term: "Fall", year: 2027 },
+ { id: "default-winter-2028", label: "Winter 2028", term: "Winter", year: 2028 },
+ { id: "default-spring-2028", label: "Spring 2028", term: "Spring", year: 2028 },
+ { id: "default-summer-2028", label: "Summer 2028", term: "Summer", year: 2028 },
+];
diff --git a/frontend/app/api/catalog/terms/route.ts b/frontend/app/api/catalog/terms/route.ts
index 159d0f4..2895655 100644
--- a/frontend/app/api/catalog/terms/route.ts
+++ b/frontend/app/api/catalog/terms/route.ts
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { createClient } from "@/utils/supabaseServerClient";
+import { DEFAULT_CATALOG_TERMS } from "../default-catalog-terms";
type TermRow = {
id: string;
@@ -46,12 +47,13 @@ export async function GET() {
return NextResponse.json({ error: error.message }, { status: 500 });
}
- const terms = (rows ?? []).map((t) => ({
+ const fromDb = (rows ?? []).map((t) => ({
id: t.id,
label: t.label,
term: t.term,
year: t.year,
}));
+ const terms = fromDb.length > 0 ? fromDb : DEFAULT_CATALOG_TERMS;
return NextResponse.json({ terms }, { status: 200 });
}
diff --git a/frontend/app/api/course-submissions/route.ts b/frontend/app/api/course-submissions/route.ts
index dd76601..11564b1 100644
--- a/frontend/app/api/course-submissions/route.ts
+++ b/frontend/app/api/course-submissions/route.ts
@@ -101,8 +101,7 @@ export async function POST(request: Request) {
const resendApiKey = process.env.RESEND_API_KEY?.trim();
const fromEmail = process.env.COURSE_REQUEST_NOTIFY_FROM_EMAIL?.trim();
- const hasEmailConfig = Boolean(notifyEmail && resendApiKey && fromEmail);
- if (!hasEmailConfig) {
+ if (!notifyEmail || !resendApiKey || !fromEmail) {
const missing: string[] = [];
if (!notifyEmail) missing.push("COURSE_REQUEST_NOTIFY_EMAIL");
if (!resendApiKey) missing.push("RESEND_API_KEY");
diff --git a/frontend/app/api/credits/route.ts b/frontend/app/api/credits/route.ts
index 8c8a38b..3a0dd71 100644
--- a/frontend/app/api/credits/route.ts
+++ b/frontend/app/api/credits/route.ts
@@ -48,10 +48,34 @@ export async function GET() {
return NextResponse.json({ error: voucherError.message }, { status: 500 });
}
+ // count uploads rewarded to this profile
+ const { count: uploadCount, error: uploadError } = await supabase
+ .from("credits_ledger")
+ .select("id", { count: "exact", head: true })
+ .eq("profile_id", user.id)
+ .eq("metadata->>reason", "upload_reward");
+
+ if (uploadError) {
+ return NextResponse.json({ error: uploadError.message }, { status: 500 });
+ }
+
+ // count upvotes cast by this profile
+ const { count: upvoteCount, error: upvoteError } = await supabase
+ .from("votes")
+ .select("id", { count: "exact", head: true })
+ .eq("profile_id", user.id)
+ .eq("value", 1);
+
+ if (upvoteError) {
+ return NextResponse.json({ error: upvoteError.message }, { status: 500 });
+ }
+
return NextResponse.json(
{
credits: creditData?.credit_score ?? 0,
freeDownloads: voucherCount ?? 0,
+ uploadCount: uploadCount ?? 0,
+ upvoteCount: upvoteCount ?? 0,
},
{ status: 200 },
);
diff --git a/frontend/app/api/department-submissions/route.ts b/frontend/app/api/department-submissions/route.ts
new file mode 100644
index 0000000..5b6c699
--- /dev/null
+++ b/frontend/app/api/department-submissions/route.ts
@@ -0,0 +1,137 @@
+import { NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { Resend } from "resend";
+import { createClient } from "@/utils/supabaseServerClient";
+
+type SubmissionPayload = {
+ department_name?: string | null;
+ justification?: string | null;
+};
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+export async function POST(request: Request) {
+ 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 });
+ }
+
+ let payload: SubmissionPayload;
+ try {
+ payload = (await request.json()) as SubmissionPayload;
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ } catch (error) {
+ return NextResponse.json(
+ { error: "Invalid request payload." },
+ { status: 400 }
+ );
+ }
+
+ const departmentName =
+ typeof payload.department_name === "string" && payload.department_name.trim()
+ ? payload.department_name.trim()
+ : "";
+
+ if (!departmentName) {
+ return NextResponse.json(
+ { error: "Department name is required." },
+ { status: 400 }
+ );
+ }
+
+ const justification =
+ typeof payload.justification === "string" && payload.justification.trim()
+ ? payload.justification.trim()
+ : null;
+
+ const { error: insertError } = await supabase
+ .from("department_submissions")
+ .insert({
+ submitter_id: user.id,
+ department_name: departmentName,
+ justification,
+ });
+
+ if (insertError) {
+ return NextResponse.json({ error: insertError.message }, { status: 500 });
+ }
+
+ const notifyEmail = process.env.COURSE_REQUEST_NOTIFY_EMAIL?.trim();
+ const resendApiKey = process.env.RESEND_API_KEY?.trim();
+ const fromEmail = process.env.COURSE_REQUEST_NOTIFY_FROM_EMAIL?.trim();
+
+ const hasEmailConfig = Boolean(notifyEmail && resendApiKey && fromEmail);
+ if (!hasEmailConfig) {
+ const missing: string[] = [];
+ if (!notifyEmail) missing.push("COURSE_REQUEST_NOTIFY_EMAIL");
+ if (!resendApiKey) missing.push("RESEND_API_KEY");
+ if (!fromEmail) missing.push("COURSE_REQUEST_NOTIFY_FROM_EMAIL");
+ console.warn(
+ "[department-submissions] Moderator email not sent: missing or empty env.",
+ "Missing:",
+ missing.join(", ")
+ );
+ } else if (
+ notifyEmail === "your-email@example.com" ||
+ resendApiKey === "re_your_api_key_here"
+ ) {
+ console.warn(
+ "[department-submissions] Moderator email not sent: .env.local still has placeholder values."
+ );
+ } else {
+ try {
+ const resend = new Resend(resendApiKey!);
+ const submitterEmail = user.email ?? "(not shared)";
+ await resend.emails.send({
+ from: fromEmail!,
+ to: [notifyEmail!],
+ subject: `[Poly Pages] New department request: ${departmentName}`,
+ html: [
+ "New department request
",
+ "A user has requested a new department be added to the catalog.
",
+ "",
+ `| Department | ${escapeHtml(departmentName)} |
`,
+ justification
+ ? `| Justification | ${escapeHtml(justification)} |
`
+ : "",
+ `| Submitter email | ${escapeHtml(submitterEmail)} |
`,
+ "
",
+ "Poly Pages – Department request notification
",
+ ].join(""),
+ });
+ } catch (err) {
+ console.error(
+ "[department-submissions] Failed to send moderator notification email:",
+ err
+ );
+ }
+ }
+
+ return NextResponse.json({ ok: true }, { status: 201 });
+}
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/[id]/preview/route.ts b/frontend/app/api/notes/[id]/preview/route.ts
new file mode 100644
index 0000000..8f928a3
--- /dev/null
+++ b/frontend/app/api/notes/[id]/preview/route.ts
@@ -0,0 +1,88 @@
+/**
+ * GET /api/notes/[id]/preview — Stream the note's preview image or PDF through the backend.
+ * Requires authentication (cookie or Bearer). No signed storage URL is exposed; inspect cannot
+ * obtain a shareable or long-lived link to the asset.
+ */
+
+import { NextResponse } from "next/server";
+import { createClient as createSupabaseClient } from "@supabase/supabase-js";
+import { createClient } from "@/utils/supabaseServerClient";
+
+type ResourceRow = {
+ id: string;
+ preview_key: string | null;
+ status: string;
+};
+
+export async function GET(
+ 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 authHeader = req.headers.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) {
+ return new NextResponse(null, { status: 401 });
+ }
+
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!supabaseUrl || !supabaseServiceRoleKey) {
+ return new NextResponse(null, { status: 500 });
+ }
+
+ const { data: resource, error: resourceError } = await supabase
+ .from("resources")
+ .select("id, preview_key, status")
+ .eq("id", resourceId)
+ .single()
+ .returns();
+
+ if (resourceError || !resource) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ if (resource.status !== "active") {
+ return new NextResponse(null, { status: 403 });
+ }
+
+ const pathToStream = resource.preview_key;
+ if (!pathToStream) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey);
+ const { data: fileData, error: fileError } = await adminClient.storage
+ .from("resources")
+ .download(pathToStream);
+
+ if (fileError || !fileData) {
+ return new NextResponse(null, { status: 404 });
+ }
+
+ const isPdf = pathToStream.toLowerCase().endsWith(".pdf");
+ const contentType = isPdf ? "application/pdf" : (fileData.type || "image/jpeg");
+ const buffer = Buffer.from(await fileData.arrayBuffer());
+
+ return new NextResponse(buffer, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "private, max-age=300",
+ "Content-Disposition": "inline",
+ },
+ });
+}
diff --git a/frontend/app/api/notes/[id]/view/route.ts b/frontend/app/api/notes/[id]/view/route.ts
new file mode 100644
index 0000000..2bdb6ba
--- /dev/null
+++ b/frontend/app/api/notes/[id]/view/route.ts
@@ -0,0 +1,133 @@
+/**
+ * GET /api/notes/[id]/view — View the note PDF in-browser (downloaded notes only).
+ * - Accept: application/json (default) → returns { url: signedUrl }.
+ * - Accept: application/pdf or ?stream=1 → returns the PDF bytes (for in-app preview, like upload).
+ */
+
+import { NextResponse } from "next/server";
+import { createClient as createSupabaseClient } from "@supabase/supabase-js";
+import { createClient } from "@/utils/supabaseServerClient";
+import { generateSignedUrl } from "@/lib/storage";
+
+const VIEW_URL_TTL_SECONDS = 3600; // 1 hour
+
+type ResourceRow = {
+ id: string;
+ file_key: string;
+ profile_id: string;
+ status: string;
+};
+
+export async function GET(
+ 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 authHeader = req.headers.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) {
+ return NextResponse.json({ error: "Not authenticated." }, { status: 401 });
+ }
+
+ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const supabaseServiceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!supabaseUrl || !supabaseServiceRoleKey) {
+ return NextResponse.json(
+ { error: "Server configuration error." },
+ { status: 500 },
+ );
+ }
+
+ const adminClient = createSupabaseClient(supabaseUrl, supabaseServiceRoleKey);
+
+ const { data: resource, error: resourceError } = await supabase
+ .from("resources")
+ .select("id, file_key, profile_id, status")
+ .eq("id", resourceId)
+ .single()
+ .returns();
+
+ if (resourceError || !resource) {
+ return NextResponse.json({ error: "Resource not found." }, { status: 404 });
+ }
+
+ if (resource.status !== "active") {
+ return NextResponse.json(
+ { error: "Resource is not available for viewing." },
+ { status: 403 },
+ );
+ }
+
+ const isOwner = resource.profile_id === user.id;
+ let canView = isOwner;
+
+ if (!canView) {
+ const { data: download } = await supabase
+ .from("resource_downloads")
+ .select("id")
+ .eq("resource_id", resourceId)
+ .eq("profile_id", user.id)
+ .maybeSingle();
+ canView = Boolean(download?.id);
+ }
+
+ if (!canView) {
+ return NextResponse.json(
+ { error: "Download this note to view it." },
+ { status: 403 },
+ );
+ }
+
+ const acceptPdf =
+ req.headers.get("accept")?.toLowerCase().includes("application/pdf") ?? false;
+ const streamParam = new URL(req.url || "", "http://x").searchParams.get("stream");
+ const returnStream = acceptPdf || streamParam === "1";
+
+ if (returnStream) {
+ const { data: fileData, error: fileError } = await adminClient.storage
+ .from("resources")
+ .download(resource.file_key);
+
+ if (fileError || !fileData) {
+ return NextResponse.json(
+ { error: "Failed to load PDF." },
+ { status: 500 },
+ );
+ }
+
+ const fileBuffer = Buffer.from(await fileData.arrayBuffer());
+ return new NextResponse(fileBuffer, {
+ status: 200,
+ headers: {
+ "Content-Type": fileData.type || "application/pdf",
+ "Content-Disposition": "inline",
+ "Cache-Control": "private, max-age=300",
+ },
+ });
+ }
+
+ try {
+ const url = await generateSignedUrl(
+ "resources",
+ resource.file_key,
+ VIEW_URL_TTL_SECONDS,
+ );
+ return NextResponse.json({ url }, { status: 200 });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Failed to generate view URL.";
+ return NextResponse.json({ error: message }, { status: 500 });
+ }
+}
diff --git a/frontend/app/api/notes/route.ts b/frontend/app/api/notes/route.ts
index 05464df..8cdb25f 100644
--- a/frontend/app/api/notes/route.ts
+++ b/frontend/app/api/notes/route.ts
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
-import { generateSignedUrls } from "@/lib/storage";
import { createClient } from "@/utils/supabaseServerClient";
+const RESOURCE_TYPE_FILTERS = new Set([
+ "lecture_notes",
+ "study_guide",
+ "class_overview",
+]);
+
type ResourceRow = {
id: string;
title: string;
@@ -13,6 +18,7 @@ type ResourceRow = {
file_key: string | null;
preview_key: string | null;
download_cost: number;
+ resource_type: string | null;
profiles: {
display_name: string | null;
} | null;
@@ -65,6 +71,12 @@ 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)
+ ? resourceTypeParam
+ : null;
const page = Number(searchParams.get("page") ?? "1");
const pageSize = Number(searchParams.get("page_size") ?? "16");
const sort = searchParams.get("sort") === "oldest" ? "oldest" : "newest";
@@ -72,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) {
@@ -88,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(
@@ -100,6 +133,7 @@ export async function GET(req: Request) {
file_key,
preview_key,
download_cost,
+ resource_type,
profiles ( display_name )
`,
)
@@ -111,11 +145,19 @@ 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);
+ }
- // Full-text search using the FTS index (searches title and description)
+ // Keyword search: title, description, and extracted PDF text (typed or OCR)
if (searchQuery && searchQuery.trim()) {
const searchTerm = `%${searchQuery.trim()}%`;
- query = query.or(`title.ilike.${searchTerm},description.ilike.${searchTerm}`);
+ query = query.or(
+ `title.ilike.${searchTerm},description.ilike.${searchTerm},extracted_text.ilike.${searchTerm}`
+ );
}
query = query.order("created_at", { ascending: sort === "oldest" });
@@ -173,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")
@@ -184,27 +225,27 @@ export async function GET(req: Request) {
});
}
- 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 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) => {
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;
+ const hasPreview = Boolean(row.preview_key);
+ const previewUrl = hasPreview ? `/api/notes/${row.id}/preview` : null;
+ const previewIsPdf =
+ Boolean(row.preview_key?.toLowerCase().endsWith(".pdf"));
return {
id: row.id,
@@ -213,6 +254,7 @@ export async function GET(req: Request) {
created_at: row.created_at,
description: row.description ?? null,
storage_path: row.file_key,
+ resource_type: row.resource_type ?? null,
profile_display_name: row.profiles?.display_name ?? null,
upvote_count: stats.upvotes,
downvote_count: stats.downvotes,
@@ -220,7 +262,9 @@ export async function GET(req: Request) {
my_vote: myVote,
download_cost: row.download_cost ?? 0,
downloaded: downloadedIds.has(row.id),
- previewUrl: path ? previewUrlMap.get(path) ?? null : null,
+ favorited: favoritedIds.has(row.id),
+ previewUrl,
+ previewIsPdf,
};
});
diff --git a/frontend/app/api/profile/stats/route.ts b/frontend/app/api/profile/stats/route.ts
index 90a6f4a..51384e7 100644
--- a/frontend/app/api/profile/stats/route.ts
+++ b/frontend/app/api/profile/stats/route.ts
@@ -114,6 +114,7 @@ export async function GET() {
const { creditsEarned, creditsSpent } = sumCreditTotals(ledgerRows ?? []);
const { rank, totalContributors } = getAllTimeRank(leaderboardRows ?? [], userId);
+ const profileCreditScore = (profileData as ProfileRow | null)?.credit_score ?? null;
return NextResponse.json(
{
@@ -122,7 +123,7 @@ export async function GET() {
totalUpvotes,
creditsEarned,
creditsSpent,
- netCredits: normalizeNetCredits(profileData?.credit_score),
+ netCredits: normalizeNetCredits(profileCreditScore),
},
rank: {
allTime: rank,
diff --git a/frontend/app/api/textextractor/route.ts b/frontend/app/api/textextractor/route.ts
new file mode 100644
index 0000000..d729def
--- /dev/null
+++ b/frontend/app/api/textextractor/route.ts
@@ -0,0 +1,78 @@
+/**
+ * TEXTEXTRACTOR API — POST a PDF, get extracted text (typed or OCR for handwritten/scanned).
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+import { extract, type ExtractOptions } from "@/lib/textextractor";
+
+const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
+const PDF_MIME_TYPES = new Set(["application/pdf"]);
+
+export async function POST(req: NextRequest) {
+ const formData = await req.formData();
+ const file = formData.get("file") ?? formData.get("pdf");
+ const forceOcr = formData.get("force_ocr") === "true" || formData.get("force_ocr") === "1";
+ const maxOcrPagesParam = formData.get("max_ocr_pages");
+ const maxOcrPages = maxOcrPagesParam ? Math.min(50, Math.max(1, Number(maxOcrPagesParam))) : undefined;
+
+ if (!(file instanceof File)) {
+ return NextResponse.json(
+ { error: "A PDF file is required. Send as form field 'file' or 'pdf'." },
+ { status: 400 }
+ );
+ }
+
+ const isPdf =
+ file.type?.toLowerCase() === "application/pdf" ||
+ file.name?.toLowerCase().endsWith(".pdf");
+ if (!isPdf) {
+ return NextResponse.json(
+ { error: "Only PDF files are supported." },
+ { status: 400 }
+ );
+ }
+
+ if (file.size === 0 || file.size > MAX_FILE_SIZE_BYTES) {
+ return NextResponse.json(
+ {
+ error: `File must be between 1 byte and ${MAX_FILE_SIZE_BYTES} bytes.`,
+ },
+ { status: 400 }
+ );
+ }
+
+ let buffer: Buffer;
+ try {
+ buffer = Buffer.from(await file.arrayBuffer());
+ } catch (e) {
+ const message = e instanceof Error ? e.message : "Unknown error";
+ return NextResponse.json(
+ { error: "Failed to read file.", details: message },
+ { status: 400 }
+ );
+ }
+
+ const options: ExtractOptions = {};
+ if (forceOcr) options.forceOcr = true;
+ if (maxOcrPages != null) options.maxOcrPages = maxOcrPages;
+
+ try {
+ const result = await extract(buffer, options);
+ return NextResponse.json({
+ text: result.text,
+ method: result.method,
+ pages_processed: result.pagesProcessed,
+ total_pages: result.totalPages,
+ ...(result.ocrConfidence != null && {
+ ocr_confidence: Math.round(result.ocrConfidence * 100) / 100,
+ }),
+ });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Extraction failed";
+ console.error("[textextractor]", err);
+ return NextResponse.json(
+ { error: "Text extraction failed.", details: message },
+ { status: 500 }
+ );
+ }
+}
diff --git a/frontend/app/api/upload/helpers/preview.ts b/frontend/app/api/upload/helpers/preview.ts
new file mode 100644
index 0000000..52a0101
--- /dev/null
+++ b/frontend/app/api/upload/helpers/preview.ts
@@ -0,0 +1,22 @@
+//**Purpose:** Given a path to a PDF file on disk, render the first page to an image, blur it, and return a JPEG
+//buffer.
+
+import sharp from "sharp";
+import { fromPath } from "pdf2pic";
+
+const PREVIEW_WIDTH = 400;
+const BLUR_SIGMA = 6;
+
+export async function generateBlurredFirstPageBuffer(pdfPath: string): Promise {
+ const convert = fromPath(pdfPath, { density: 150 });
+ const result = await convert(1, { format: "png" });
+ if (!result?.path) throw new Error("Failed to render PDF first page");
+
+ const blurred = await sharp(result.path)
+ .resize(PREVIEW_WIDTH)
+ .blur(BLUR_SIGMA)
+ .jpeg({ quality: 80 })
+ .toBuffer();
+
+ return blurred;
+}
\ No newline at end of file
diff --git a/frontend/app/api/upload/route.ts b/frontend/app/api/upload/route.ts
index f88c0df..ade29fc 100644
--- a/frontend/app/api/upload/route.ts
+++ b/frontend/app/api/upload/route.ts
@@ -2,14 +2,23 @@ import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { extract } from "@/lib/textextractor";
+import { generateBlurredFirstPageBuffer } from "./helpers/preview";
+
+/** Max characters of extracted text to store for search (avoids huge rows). */
+const MAX_EXTRACTED_TEXT_LENGTH = 100_000;
+
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const PDF_MIME_TYPES = new Set(["application/pdf"]);
+const STORAGE_ALLOWED_MIME_TYPES = new Set(["application/pdf", "image/jpeg"]);
const STORAGE_BUCKET = "resources";
const RESOURCE_TYPES = new Set([
"lecture_notes",
"study_guide",
"class_overview",
- "link",
]);
const isUuid = (value: string) =>
@@ -33,23 +42,53 @@ const buildFilePath = (userId: string, originalName: string) => {
return `${userId}/${suffix}-${normalizedBase}.pdf`;
};
+function buildPreviewPath(pdfFilePath: string): string {
+ const parts = pdfFilePath.split("/");
+ const fileName = parts.pop() ?? "";
+ const userId = parts[0] ?? "unknown";
+ const baseName = fileName.replace(/\.pdf$/i, "");
+ const uuidMatch = baseName.match(
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
+ );
+ const id = uuidMatch ? uuidMatch[1] : baseName || randomUUID();
+ return `previews/${userId}/${id}.jpg`;
+}
+
const ensureResourcesBucket = async (adminClient: SupabaseClient) => {
const { data: buckets, error: listError } = await adminClient.storage.listBuckets();
if (listError) {
throw listError;
}
- const exists = buckets?.some((bucket) => bucket.name === STORAGE_BUCKET);
- if (exists) return;
+ const existing = buckets?.find((bucket) => bucket.name === STORAGE_BUCKET);
+ const desiredMimeTypes = Array.from(STORAGE_ALLOWED_MIME_TYPES);
+
+ if (!existing) {
+ const { error: createError } = await adminClient.storage.createBucket(STORAGE_BUCKET, {
+ public: false,
+ fileSizeLimit: `${MAX_FILE_SIZE_BYTES}`,
+ allowedMimeTypes: desiredMimeTypes,
+ });
+
+ if (createError && !createError.message.toLowerCase().includes("already exists")) {
+ throw createError;
+ }
+ return;
+ }
+
+ const existingMimeTypes = existing.allowed_mime_types ?? [];
+ const hasAllMimeTypes = desiredMimeTypes.every((mime) => existingMimeTypes.includes(mime));
+ if (hasAllMimeTypes) {
+ return;
+ }
- const { error: createError } = await adminClient.storage.createBucket(STORAGE_BUCKET, {
+ const { error: updateError } = await adminClient.storage.updateBucket(STORAGE_BUCKET, {
public: false,
fileSizeLimit: `${MAX_FILE_SIZE_BYTES}`,
- allowedMimeTypes: Array.from(PDF_MIME_TYPES),
+ allowedMimeTypes: desiredMimeTypes,
});
-
- if (createError && !createError.message.toLowerCase().includes("already exists")) {
- throw createError;
+ if (updateError) {
+ throw updateError;
}
};
@@ -172,6 +211,60 @@ export async function POST(req: NextRequest) {
},
})
: adminClient;
+
+ let previewKey: string | null = null;
+ const tmpPdf = path.join(os.tmpdir(), `upload-${randomUUID()}.pdf`);
+ try {
+ fs.writeFileSync(tmpPdf, fileBuffer);
+ const blurredBuffer = await generateBlurredFirstPageBuffer(tmpPdf);
+ const previewPath = buildPreviewPath(filePath);
+ const { error: previewUploadError } = await adminClient.storage
+ .from(STORAGE_BUCKET)
+ .upload(previewPath, blurredBuffer, {
+ cacheControl: "3600",
+ contentType: "image/jpeg",
+ upsert: false,
+ });
+ if (previewUploadError) {
+ console.error("Preview upload failed", previewUploadError);
+ } else {
+ previewKey = previewPath;
+ }
+ } catch (previewErr) {
+ console.error("Preview generation failed", previewErr);
+ } finally {
+ if (fs.existsSync(tmpPdf)) {
+ fs.unlinkSync(tmpPdf);
+ }
+ }
+
+ // If blurred preview failed, fall back to file_key so we still have a preview_key (required by schema).
+ // Notes API will return a signed URL for the PDF and the UI can show a blurred PDF preview.
+ if (previewKey == null) {
+ previewKey = filePath;
+ }
+
+ let extractedText: string | null = null;
+ try {
+ const result = await extract(fileBuffer, { maxOcrPages: 10 });
+ if (result.text?.trim()) {
+ extractedText =
+ result.text.length > MAX_EXTRACTED_TEXT_LENGTH
+ ? result.text.slice(0, MAX_EXTRACTED_TEXT_LENGTH)
+ : result.text;
+ }
+ } catch (extractErr) {
+ console.warn("[upload] Text extraction failed, storing without extracted_text:", extractErr);
+ }
+
+ // Only admin/moderator/developer roles get notes auto-approved (for test notes); others stay pending for moderator review.
+ let status: "pending" | "active" = "pending";
+ const { data: roles } = await adminClient
+ .from("user_roles")
+ .select("role")
+ .eq("profile_id", userId)
+ .in("role", ["admin", "moderator", "developer"]);
+ if (roles && roles.length > 0) status = "active";
const { data: resource, error: insertError } = await supabase
.from("resources")
@@ -182,7 +275,9 @@ export async function POST(req: NextRequest) {
resource_type: resourceType,
description: description || null,
file_key: filePath,
- preview_key: filePath,
+ preview_key: previewKey,
+ ...(status === "active" && { status: "active" }),
+ ...(extractedText != null && { extracted_text: extractedText }),
})
.select()
.single();
diff --git a/frontend/app/auth/page.tsx b/frontend/app/auth/page.tsx
index 4f6adaf..ebf435d 100644
--- a/frontend/app/auth/page.tsx
+++ b/frontend/app/auth/page.tsx
@@ -1,11 +1,11 @@
"use client";
-import { useEffect, useState } from "react";
+import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient";
import "./auth.css";
-export default function AuthPage() {
+function AuthPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirect") || "/auth/callback";
@@ -159,3 +159,17 @@ export default function AuthPage() {
return {content};
}
+
+export default function AuthPage() {
+ return (
+
+ Loading…
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/components/DesignNav.tsx b/frontend/app/components/DesignNav.tsx
index e0b51db..fdbba05 100644
--- a/frontend/app/components/DesignNav.tsx
+++ b/frontend/app/components/DesignNav.tsx
@@ -28,9 +28,15 @@ export function DesignNav({
}>
+
+
+ );
+}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
index 428c77b..a07c622 100644
--- a/frontend/app/dashboard/page.tsx
+++ b/frontend/app/dashboard/page.tsx
@@ -60,6 +60,18 @@ const emptyCourseRequest: CourseRequestForm = {
justification: "",
};
+type DepartmentRequestForm = {
+ departmentName: string;
+ justification: string;
+};
+
+type DepartmentRequestStatus = "idle" | "submitting" | "success" | "error";
+
+const emptyDepartmentRequest: DepartmentRequestForm = {
+ departmentName: "",
+ justification: "",
+};
+
export default function DashboardPage() {
const [courses, setCourses] = useState([]);
const [selectedDepartment, setSelectedDepartment] = useState(null);
@@ -77,6 +89,14 @@ export default function DashboardPage() {
const [freeDownloads, setFreeDownloads] = useState(null);
/** Number of course cards to render (paginated for performance). */
const [visibleCourseCount, setVisibleCourseCount] = useState(80);
+
+ // department submission UI state
+ const [showDeptRequestForm, setShowDeptRequestForm] = useState(false);
+ const [deptRequestName, setDeptRequestName] = useState("");
+ const [deptRequestNumber, setDeptRequestNumber] = useState("");
+ const [deptRequestStatus, setDeptRequestStatus] = useState<
+ "idle" | "loading" | "success" | "error"
+ >("idle");
/** When no department selected, whether the API has more courses to fetch. */
const [hasMoreFromApi, setHasMoreFromApi] = useState(false);
/** Page enter animation (leaderboard-style). */
@@ -96,6 +116,16 @@ export default function DashboardPage() {
string | null
>(null);
+ const [isDepartmentRequestOpen, setIsDepartmentRequestOpen] = useState(false);
+ const [departmentRequest, setDepartmentRequest] =
+ useState(emptyDepartmentRequest);
+ const [departmentRequestStatus, setDepartmentRequestStatus] =
+ useState("idle");
+ const [departmentRequestMessage, setDepartmentRequestMessage] = useState<
+ string | null
+ >(null);
+ const [catalogSectionOpen, setCatalogSectionOpen] = useState(false);
+
const refreshToken = useCallback(async () => {
const { session, error } = await getSessionWithRecovery(supabase);
if (error) return null;
@@ -123,6 +153,42 @@ export default function DashboardPage() {
const INITIAL_PAGE_SIZE = 200;
const DEPARTMENT_PAGE_SIZE = 1000;
+ const submitDeptRequest = useCallback(async () => {
+ // simple validation
+ if (!deptRequestName.trim() || !deptRequestNumber.trim()) {
+ setDeptRequestStatus("error");
+ return;
+ }
+
+ setDeptRequestStatus("loading");
+ try {
+ const { session, error: sessErr } = await getSessionWithRecovery(supabase);
+ if (sessErr || !session?.user) {
+ throw sessErr || new Error("not authenticated");
+ }
+ const userId = session.user.id;
+
+ const { error } = await supabase.from("department_submissions").insert({
+ submitter_id: userId,
+ full_name: deptRequestName.trim(),
+ department_number: deptRequestNumber.trim(),
+ });
+
+ if (error) {
+ console.log(error);
+ console.error("department submission failed", error);
+ setDeptRequestStatus("error");
+ } else {
+ setDeptRequestStatus("success");
+ setDeptRequestName("");
+ setDeptRequestNumber("");
+ }
+ } catch (err) {
+ console.error("error during department submission", err);
+ setDeptRequestStatus("error");
+ }
+ }, [deptRequestName, deptRequestNumber]);
+
const fetchCoursesPage = useCallback(
async (
token: string,
@@ -468,6 +534,64 @@ export default function DashboardPage() {
}
};
+ const openDepartmentRequest = () => {
+ setDepartmentRequest(emptyDepartmentRequest);
+ setDepartmentRequestStatus("idle");
+ setDepartmentRequestMessage(null);
+ setIsDepartmentRequestOpen(true);
+ };
+ const closeDepartmentRequest = () => setIsDepartmentRequestOpen(false);
+ const handleDepartmentRequestChange =
+ (field: keyof DepartmentRequestForm) =>
+ (event: React.ChangeEvent) => {
+ setDepartmentRequest((prev) => ({ ...prev, [field]: event.target.value }));
+ };
+ const handleDepartmentRequestSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setDepartmentRequestMessage(null);
+ if (!accessToken) {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Not authenticated. Please sign in again.");
+ return;
+ }
+ const name = departmentRequest.departmentName.trim();
+ if (!name) {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Department name is required.");
+ return;
+ }
+ setDepartmentRequestStatus("submitting");
+ try {
+ const res = await fetch("/api/department-submissions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ department_name: name,
+ justification: departmentRequest.justification.trim() || null,
+ }),
+ });
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null);
+ const message =
+ payload && typeof payload === "object" && "error" in payload
+ ? String(payload.error)
+ : "Failed to submit the request.";
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage(message);
+ return;
+ }
+ setDepartmentRequestStatus("success");
+ setDepartmentRequestMessage("Request submitted. We will review it soon.");
+ setDepartmentRequest(emptyDepartmentRequest);
+ } catch {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Failed to submit the request. Try again.");
+ }
+ };
+
/** When no filter and user has typed: use search API results (cached). Otherwise use main courses list. */
const isSearchMode = searchResults !== null && selectedDepartment == null && browseSearch.trim().length > 0;
@@ -589,9 +713,33 @@ export default function DashboardPage() {
className={`browse-sidebar page-enter ${isVisible ? "page-enter-visible" : "page-enter-hidden"}`}
style={{ transitionDelay: "100ms" }}
>
- Filters
+
+
Filters
+
+
-
Department
+
+ Department{' '}
+ {
+ e.preventDefault();
+ setShowDeptRequestForm((prev) => !prev);
+ setDeptRequestStatus("idle");
+ }}
+ >
+ Don't see your department? Request it
+
+
+ {showDeptRequestForm && (
+
+ setDeptRequestName(e.target.value)}
+ />
+ setDeptRequestNumber(e.target.value)}
+ />
+
+ {deptRequestStatus === "success" && (
+ Request submitted!
+ )}
+ {deptRequestStatus === "error" && (
+ Failed to submit
+ )}
+
+ )}
{filteredDepartments.map((dept) => (
-
-
2026-2028 Catalog
-
- {catalogTerms.map((t) => (
-
- ))}
- {catalogTerms.length === 0 && (
- No terms
- )}
-
-
- {hasActiveFilters && (
-
- )}
-
+
+ {catalogSectionOpen && (
+
+ {catalogTerms.map((t) => (
+
+ ))}
+ {catalogTerms.length === 0 && (
+ No terms
+ )}
+
+ )}
+
+
+
+
+
+
@@ -876,6 +1070,91 @@ export default function DashboardPage() {
,
document.body
)}
+
+ {isDepartmentRequestOpen &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+
event.stopPropagation()}
+ >
+
+
+ Request a new department
+
+
+
+
+
+
,
+ document.body
+ )}
);
}
diff --git a/frontend/app/dashboard/profile-dashboard/page.tsx b/frontend/app/dashboard/profile-dashboard/page.tsx
index 4f5fd7c..30527ce 100644
--- a/frontend/app/dashboard/profile-dashboard/page.tsx
+++ b/frontend/app/dashboard/profile-dashboard/page.tsx
@@ -25,48 +25,51 @@ type ProfileNote = {
downloaded: boolean;
};
+/** Derive avatar initials from display name (e.g. "Violet Peacock" → "VP", "VioletPeacock" → "Vi"). */
+function getInitialsFromDisplayName(displayName: string): string {
+ const s = displayName.trim();
+ if (!s) return "?";
+ const parts = s.split(/\s+/).filter(Boolean);
+ if (parts.length >= 2) {
+ const first = parts[0][0] ?? "";
+ const last = parts[parts.length - 1][0] ?? "";
+ return (first + last).toUpperCase().slice(0, 2);
+ }
+ return s.slice(0, 2).toUpperCase();
+}
+
function getDisplayInitial(user: User | null): string {
if (!user) return "?";
- const name = user.user_metadata?.full_name as string | undefined;
- if (name && typeof name === "string") {
- const parts = name.trim().split(/\s+/);
- if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase().slice(0, 2);
- if (parts[0]) return parts[0].slice(0, 2).toUpperCase();
- }
const email = user.email;
if (email) return email.slice(0, 2).toUpperCase();
return "?";
}
-function getDisplayName(user: User | null): string {
- if (!user) return "Guest";
- const name = user.user_metadata?.full_name as string | undefined;
- if (name && typeof name === "string") return name.trim();
- return user.email?.split("@")[0] ?? "User";
-}
-
-function getHandle(user: User | null): string {
- if (!user) return "guest";
- return user.user_metadata?.user_name as string ?? user.email?.split("@")[0] ?? "user";
-}
-
export default function Page() {
const [user, setUser] = useState(null);
const [tokenLoaded, setTokenLoaded] = useState(false);
const [accessToken, setAccessToken] = useState(null);
const [credits, setCredits] = useState(null);
const [creditsError, setCreditsError] = useState(null);
+ // new stats
+ const [totalUploads, setTotalUploads] = useState(null);
+ const [totalUpvotes, setTotalUpvotes] = useState(null);
const [loggingOut, setLoggingOut] = useState(false);
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);
const [loadingStats, setLoadingStats] = useState(false);
const [statsError, setStatsError] = useState(null);
+ const [profileDisplayName, setProfileDisplayName] = useState(null);
+ const [profileHandle, setProfileHandle] = useState(null);
const [isVisible, setIsVisible] = useState(false);
const router = useRouter();
@@ -85,6 +88,29 @@ export default function Page() {
loadSession();
}, []);
+ useEffect(() => {
+ if (!user?.id) {
+ setProfileDisplayName(null);
+ setProfileHandle(null);
+ return;
+ }
+ let cancelled = false;
+ (async () => {
+ const { data } = await supabase
+ .from("profiles")
+ .select("display_name, handle")
+ .eq("id", user.id)
+ .maybeSingle();
+ if (cancelled) return;
+ const row = data as { display_name: string | null; handle: string | null } | null;
+ setProfileDisplayName(row?.display_name?.trim() ?? null);
+ setProfileHandle(row?.handle?.trim() ?? null);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [user?.id]);
+
const refreshCredits = useCallback(async () => {
const { session } = await getSessionWithRecovery(supabase);
if (!session?.access_token) {
@@ -98,14 +124,20 @@ export default function Page() {
if (!res.ok) {
setCreditsError("Failed to load credits");
setCredits(null);
+ setTotalUploads(null);
+ setTotalUpvotes(null);
return;
}
const data = await res.json();
setCredits(Number.isFinite(data?.credits) ? Number(data.credits) : 0);
setCreditsError(null);
+ setTotalUploads(Number.isFinite(data?.uploadCount) ? Number(data.uploadCount) : 0);
+ setTotalUpvotes(Number.isFinite(data?.upvoteCount) ? Number(data.upvoteCount) : 0);
} catch {
setCreditsError("Failed to load credits");
setCredits(null);
+ setTotalUploads(null);
+ setTotalUpvotes(null);
}
}, []);
@@ -163,6 +195,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();
@@ -173,6 +230,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);
@@ -218,9 +280,12 @@ export default function Page() {
router.replace("/");
}, [router]);
- const initial = getDisplayInitial(user);
- const displayName = getDisplayName(user);
- const handle = getHandle(user);
+ const nickname =
+ profileDisplayName ?? profileHandle ?? user?.email?.split("@")[0] ?? "User";
+ const initial =
+ profileDisplayName ?? profileHandle
+ ? getInitialsFromDisplayName(profileDisplayName ?? profileHandle ?? "")
+ : getDisplayInitial(user);
const { theme } = useTheme();
const displayedStats = profileStats ?? null;
@@ -249,8 +314,10 @@ export default function Page() {
{initial}
-
{handle}
-
{displayName}
+
{nickname}
+ {user?.email && (
+
{user.email}
+ )}
Share notes and earn credits.
@@ -259,7 +326,7 @@ export default function Page() {
{loadingStats ? "—" : displayStatValue(leaderboardRank)}
- Top contributor this week
+ All-time contributor rank
@@ -343,7 +410,11 @@ export default function Page() {
{downloads.map((note) => (
{note.title}
@@ -365,7 +436,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 && (
+
+ )}
+ >
)}
@@ -381,13 +485,13 @@ export default function Page() {
Total Uploads
- {loadingStats ? "—" : displayStatValue(displayedStats?.totalUploads)}
+ {loadingStats ? "—" : displayStatValue(displayedStats?.totalUploads ?? totalUploads)}
Total Upvotes
- {loadingStats ? "—" : displayStatValue(displayedStats?.totalUpvotes)}
+ {loadingStats ? "—" : displayStatValue(displayedStats?.totalUpvotes ?? totalUpvotes)}
@@ -421,7 +525,9 @@ export default function Page() {
+
{theme === "dark" ? "Dark mode" : "Light mode"}
+
diff --git a/frontend/app/dashboard/profile-dashboard/profile-dashboard.css b/frontend/app/dashboard/profile-dashboard/profile-dashboard.css
index c6f97a6..5f6433c 100644
--- a/frontend/app/dashboard/profile-dashboard/profile-dashboard.css
+++ b/frontend/app/dashboard/profile-dashboard/profile-dashboard.css
@@ -228,6 +228,13 @@
margin: 0;
}
+.profile-page__email {
+ font-size: 14px;
+ line-height: 20px;
+ color: var(--poly-neutral-muted, #666);
+ margin: 0;
+}
+
.profile-page__bio {
font-size: 15px;
line-height: 24px;
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 6750578..374129e 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -200,6 +200,10 @@ select {
color: #e5e5e5 !important;
}
+[data-theme="dark"] .profile-page__email {
+ color: #a3a3a3 !important;
+}
+
[data-theme="dark"] .browse-subtitle,
[data-theme="dark"] .browse-course-code,
[data-theme="dark"] .browse-course-subline,
@@ -224,12 +228,217 @@ select {
color: var(--poly-sage);
}
+[data-theme="dark"] .browse-clear-filters {
+ background: #262626 !important;
+ border-color: rgba(255, 255, 255, 0.15) !important;
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .browse-clear-filters:hover:not(:disabled) {
+ background: var(--poly-sage-soft) !important;
+}
+
+[data-theme="dark"] .browse-clear-filters-disabled,
+[data-theme="dark"] .browse-clear-filters:disabled {
+ color: #666 !important;
+ opacity: 0.6;
+}
+
+[data-theme="dark"] .browse-catalog-toggle .browse-filter-heading {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .browse-catalog-chevron {
+ color: #a3a3a3;
+}
+
+/* Leaderboard dark mode */
+[data-theme="dark"] .leaderboard-tabs {
+ background: #262626 !important;
+ box-shadow: none !important;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ --tw-ring-color: rgba(255, 255, 255, 0.15);
+}
+
+[data-theme="dark"] .leaderboard-tab:not(.leaderboard-tab-active) {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .leaderboard-tab:not(.leaderboard-tab-active):hover {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .leaderboard-title {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .leaderboard-subtitle {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .leaderboard-podium-card,
+[data-theme="dark"] .leaderboard-list-card {
+ background: #262626 !important;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .leaderboard-podium-card .font-semibold,
+[data-theme="dark"] .leaderboard-list-card h3 {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .leaderboard-list-card p,
+[data-theme="dark"] .leaderboard-podium-card p.font-semibold {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .leaderboard-list-card .text-xs,
+[data-theme="dark"] .leaderboard-list-card p.text-sm {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .leaderboard-muted {
+ color: #a3a3a3 !important;
+}
+
+/* Note preview modal dark mode */
+[data-theme="dark"] .note-modal {
+ background: #262626 !important;
+ border-color: rgba(255, 255, 255, 0.1);
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
+}
+
+[data-theme="dark"] .note-modal-title,
+[data-theme="dark"] .note-modal-details p {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .note-modal-details strong {
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .note-modal-close {
+ color: #a3a3a3;
+}
+
+[data-theme="dark"] .note-modal-close:hover {
+ color: #e5e5e5;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+[data-theme="dark"] .note-modal-preview {
+ background: #1a1a1a !important;
+ border-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .note-modal-preview--pdf {
+ background: #1a1a1a !important;
+}
+
+[data-theme="dark"] .note-modal-preview-iframe {
+ background: #1a1a1a;
+}
+
+[data-theme="dark"] .note-modal-no-preview {
+ color: #666 !important;
+}
+
+[data-theme="dark"] .note-modal-pdf-loading {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .note-modal-pdf-nav-btn {
+ background: #2a2a2a !important;
+ border-color: rgba(255, 255, 255, 0.15);
+ color: #e5e5e5;
+}
+
+[data-theme="dark"] .note-modal-pdf-nav-btn:hover:not(:disabled) {
+ background: #3a3a3a !important;
+}
+
+[data-theme="dark"] .note-modal-pdf-page-info {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .note-modal-score-hint,
+[data-theme="dark"] .note-modal-score-desc {
+ color: #a3a3a3 !important;
+}
+
+[data-theme="dark"] .note-modal-actions {
+ background: #1f1f1f !important;
+ border-top-color: rgba(255, 255, 255, 0.1);
+}
+
+[data-theme="dark"] .note-modal-report-btn {
+ background: transparent !important;
+ border-color: rgba(255, 255, 255, 0.15);
+ color: #e5e5e5 !important;
+}
+
+[data-theme="dark"] .note-modal-report-btn:hover {
+ color: #f87171 !important;
+ 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,
@@ -368,15 +577,6 @@ select {
[data-theme="dark"] .course-detail-note-meta {
border-top-color: rgba(255, 255, 255, 0.1) !important;
}
-[data-theme="dark"] .course-detail-tabs {
- border-bottom-color: rgba(255, 255, 255, 0.12) !important;
-}
-[data-theme="dark"] .course-detail-tab {
- color: #b0b0b0 !important;
-}
-[data-theme="dark"] .course-detail-tab.active {
- color: #6dbe8b !important;
-}
[data-theme="dark"] .course-detail-filter-label,
[data-theme="dark"] .course-detail-notes-count {
color: #b0b0b0 !important;
@@ -396,11 +596,48 @@ select {
border-color: #6dbe8b !important;
color: #6dbe8b !important;
}
+[data-theme="dark"] .course-detail-search-input {
+ background: #2d2d2d !important;
+ border-color: rgba(255, 255, 255, 0.15) !important;
+ color: #e5e5e5 !important;
+}
+[data-theme="dark"] .course-detail-search-input::placeholder {
+ color: #888 !important;
+}
[data-theme="dark"] .course-detail-note-votes .course-detail-note-vote-up,
[data-theme="dark"] .course-detail-note-votes .course-detail-note-vote-down {
color: #d4d4d4 !important;
}
+/* DesignNav (top nav + mobile menu) theme alignment */
+.design-nav-header {
+ background: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+[data-theme="dark"] .design-nav-header {
+ background: #262626;
+ border-bottom-color: #404040;
+}
+
+.design-nav-mobile-menu {
+ background: #ffffff;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+[data-theme="dark"] .design-nav-mobile-menu {
+ background: #262626;
+ border-bottom-color: #404040;
+}
+
+.design-nav-burger-line {
+ background: #111827;
+}
+
+[data-theme="dark"] .design-nav-burger-line {
+ background: #e5e5e5;
+}
+
/* Upload page dark mode */
[data-theme="dark"] .upload-page--design {
--upload-bg: #1a1a1a;
@@ -1034,10 +1271,11 @@ body {
background: #cbd5e1;
}
+/* Note preview modal – matches app design system */
.note-modal-overlay {
position: fixed;
inset: 0;
- background: rgba(15, 23, 42, 0.55);
+ background: rgba(46, 46, 46, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
@@ -1048,224 +1286,387 @@ body {
}
.note-modal {
- width: min(640px, 100%);
- background: rgba(37, 37, 37, 0.95);
- backdrop-filter: blur(10px);
- -webkit-backdrop-filter: blur(10px);
- border-radius: 16px;
- padding: 1.5rem;
- box-shadow: 0 22px 50px rgba(0, 0, 0, 0.5);
- border: 1px solid #365314;
- max-height: 90vh;
- overflow-y: auto;
+ width: min(560px, 100%);
+ max-height: 90vh;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ background: #ffffff;
+ border-radius: 12px;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.12), 0 0 1px rgba(0, 0, 0, 0.08);
+ border: 1px solid var(--poly-border-subtle, rgba(0, 0, 0, 0.06));
}
.note-modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 1.5rem;
- gap: 1rem;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1.25rem 1.5rem 0;
+ flex-shrink: 0;
}
.note-modal-title {
- font-size: 1.25rem;
- font-weight: 700;
- color: #ffffff;
- margin: 0;
- flex: 1;
- word-break: break-word;
- text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 0 6px rgba(0, 0, 0, 0.3);
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--poly-neutral-dark);
+ margin: 0;
+ flex: 1;
+ min-width: 0;
+ word-break: break-word;
+ line-height: 1.35;
}
.note-modal-close {
- background: transparent;
- border: none;
- font-size: 1.5rem;
- color: #e5e7eb;
- cursor: pointer;
- padding: 0;
- width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
+ background: transparent;
+ border: none;
+ font-size: 1.5rem;
+ line-height: 1;
+ color: var(--poly-neutral-muted);
+ cursor: pointer;
+ padding: 0.375rem;
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ border-radius: 8px;
+ transition: color 0.15s, background 0.15s;
}
.note-modal-close:hover {
- color: #ffffff;
+ color: var(--poly-neutral-dark);
+ background: var(--poly-sage-soft);
}
.note-modal-content {
- display: flex;
- flex-direction: column;
- gap: 1.5rem;
- margin-bottom: 1.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ padding: 1.25rem 1.5rem;
+ overflow-y: auto;
}
.note-modal-preview {
width: 100%;
- border-radius: 12px;
+ border-radius: 10px;
overflow: hidden;
- border: 1px solid #e2e8f0;
- background: #f1f5f9;
+ border: 1px solid var(--poly-border-subtle, rgba(0, 0, 0, 0.08));
+ background: #f8fafc;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 200px;
+}
+
+/* When showing full PDF viewer: allow scroll, don't clip */
+.note-modal-preview--pdf {
+ overflow: visible;
+ min-height: 300px;
+ flex-direction: column;
+ align-items: stretch;
+}
+
+.note-modal-preview-img {
+ max-width: 100%;
+ height: auto;
+ max-height: 50vh;
+ object-fit: contain;
+ display: block;
+}
+
+/* Blurred PDF preview when no image preview (e.g. fallback from upload) */
+.note-modal-preview-iframe {
+ width: 100%;
+ min-height: 200px;
+ max-height: 50vh;
+ border: none;
+ filter: blur(3px);
+ pointer-events: none;
+ display: block;
}
-.note-modal-preview > div,
-.note-modal-preview > div > div,
-.note-modal-preview canvas {
- filter: blur(5px) !important;
- transform: scale(1.05) !important;
+.note-modal-preview img {
+ max-width: 100%;
+ height: auto;
+ max-height: 50vh;
+ object-fit: contain;
+ display: block;
}
.note-modal-no-preview {
- width: 100%;
- min-height: 200px;
- display: flex;
- align-items: center;
- justify-content: center;
- color: #94a3b8;
- font-size: 0.95rem;
+ width: 100%;
+ min-height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--poly-neutral-muted);
+ font-size: 0.9rem;
+}
+
+.note-modal-pdf-loading,
+.note-modal-pdf-error {
+ width: 100%;
+ min-height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.9rem;
+ color: var(--poly-neutral-muted);
+}
+
+.note-modal-pdf-error {
+ color: var(--poly-error, #b91c1c);
+}
+
+.note-modal-pdf-viewer {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+/* Scrollable area for the PDF page; keep compact so details and nav stay visible below */
+.note-modal-pdf-scroll {
+ width: 100%;
+ max-height: 42vh;
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.5rem 0;
+}
+
+.note-modal-pdf-viewer .react-pdf__Document {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.note-modal-pdf-viewer .react-pdf__Page {
+ max-width: 100%;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+/* Ensure PDF.js canvas and text layer are visible and not clipped */
+.note-modal-pdf-viewer .react-pdf__Page__canvas {
+ max-width: 100%;
+ height: auto !important;
+}
+
+.note-modal-pdf-nav {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ flex-wrap: wrap;
+}
+
+.note-modal-pdf-nav-btn {
+ padding: 0.4rem 0.75rem;
+ font-size: 0.875rem;
+ border: 1px solid var(--poly-border-subtle, rgba(0, 0, 0, 0.15));
+ border-radius: 8px;
+ background: var(--poly-bg, #fff);
+ color: var(--poly-neutral-dark);
+ cursor: pointer;
+}
+
+.note-modal-pdf-nav-btn:hover:not(:disabled) {
+ background: var(--poly-neutral-muted, #e5e5e5);
+}
+
+.note-modal-pdf-nav-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.note-modal-pdf-page-info {
+ font-size: 0.875rem;
+ color: var(--poly-neutral-muted);
}
.note-modal-details {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
}
.note-modal-details p {
- margin: 0;
- font-size: 0.95rem;
- color: #e5e7eb;
- line-height: 1.5;
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--poly-neutral-dark);
+ line-height: 1.5;
}
.note-modal-details strong {
- color: #4ade80;
- font-weight: 600;
+ color: var(--poly-neutral-dark);
+ font-weight: 600;
+ margin-right: 0.25rem;
}
.note-modal-score-hint {
- margin: 0;
- font-size: 0.85rem;
- color: #94a3b8;
+ margin: 0;
+ font-size: 0.85rem;
+ color: var(--poly-neutral-muted);
}
.note-modal-score-desc {
- font-weight: normal;
- color: #64748b;
+ font-weight: 400;
+ color: var(--poly-neutral-muted);
}
.note-modal-vote-row {
- display: flex;
- align-items: center;
- gap: 1rem;
- margin-top: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 0.5rem;
}
.note-modal-vote-arrow {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 2px 0;
- font-size: 0.95rem;
- border: none;
- background: transparent;
- cursor: pointer;
- transition: all 150ms ease;
- font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.35rem 0.5rem;
+ font-size: 0.9rem;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ border-radius: 6px;
+ transition: background 0.15s, color 0.15s;
+ font-weight: 500;
}
.note-modal-vote-arrow:hover:not(:disabled) {
- filter: brightness(1.15);
+ background: var(--poly-sage-soft);
}
.note-modal-vote-arrow:disabled {
- opacity: 0.7;
- cursor: not-allowed;
+ opacity: 0.6;
+ cursor: not-allowed;
}
.note-modal-vote-up {
- color: #9be2b2;
-}
-
-.note-modal-vote-up:hover:not(:disabled) {
- background: transparent;
+ color: var(--poly-sage);
}
.note-modal-vote-up.note-modal-vote-active {
- background: transparent;
- color: #4ade80;
+ color: var(--poly-sage);
+ background: var(--poly-sage-soft);
}
.note-modal-vote-down {
- color: #f2a0a0;
+ color: var(--poly-neutral-muted);
}
.note-modal-vote-down:hover:not(:disabled) {
- background: transparent;
+ color: #b91c1c;
+ background: rgba(185, 28, 28, 0.08);
}
.note-modal-vote-down.note-modal-vote-active {
- background: transparent;
- color: #ef4444;
+ color: #b91c1c;
+ background: rgba(185, 28, 28, 0.08);
}
.note-modal-vote-count {
- min-width: 1.25em;
- text-align: left;
+ min-width: 1.25em;
+ text-align: left;
}
.note-modal-vote-row-static .note-modal-vote-arrow {
- pointer-events: none;
- cursor: default;
+ pointer-events: none;
+ cursor: default;
}
.note-modal-vote-static:hover {
- filter: none;
+ background: transparent;
}
.note-modal-details .dashboard-vote-error {
- margin-top: 0.5rem;
- font-size: 0.85rem;
- color: #f2a0a0;
+ margin-top: 0.5rem;
+ font-size: 0.85rem;
+ color: #b91c1c;
}
.note-modal-actions {
- display: flex;
- justify-content: flex-end;
- gap: 0.75rem;
- padding-top: 1rem;
- border-top: 1px solid #e2e8f0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding: 1rem 1.5rem 1.25rem;
+ border-top: 1px solid var(--poly-border-subtle, rgba(0, 0, 0, 0.06));
+ background: #fafafa;
+ flex-shrink: 0;
+ min-width: 0;
}
-.note-modal-report-btn,
-.note-modal-close-btn {
- border-radius: 9999px;
- padding: 0.6rem 1.2rem;
- font-size: 0.9rem;
- font-weight: 600;
- border: none;
- cursor: pointer;
- transition: all 0.2s ease;
+.note-modal-download-btn {
+ flex-shrink: 0;
+ margin-right: auto;
+}
+
+.note-modal-actions-votes {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ flex-shrink: 0;
}
+.note-modal-actions-secondary {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+
.note-modal-report-btn {
- background: #ef4444;
- color: #ffffff;
+ border-radius: 8px;
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border: 1px solid var(--poly-border-subtle, rgba(0, 0, 0, 0.1));
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
+ background: transparent;
+ color: var(--poly-neutral-muted);
+ flex-shrink: 0;
}
.note-modal-report-btn:hover {
- background: #dc2626;
+ 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-close-btn {
- background: #e2e8f0;
- color: #0f172a;
+.note-modal-star-btn:hover {
+ color: #d97706;
+ background: rgba(245, 158, 11, 0.16);
}
-.note-modal-close-btn:hover {
- background: #cbd5e1;
+.note-modal-star-btn.is-active {
+ color: #ca8a04;
+ background: rgba(250, 204, 21, 0.18);
+ border-color: rgba(245, 158, 11, 0.65);
}
diff --git a/frontend/app/leaderboard/page.tsx b/frontend/app/leaderboard/page.tsx
index 05fb07a..7d64015 100644
--- a/frontend/app/leaderboard/page.tsx
+++ b/frontend/app/leaderboard/page.tsx
@@ -99,18 +99,18 @@ export default function LeaderboardPage() {
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
- Leaderboard
-
+
Leaderboard
+
{period === "all_time" ? "Ranked by total uploaded notes" : "Ranked by notes uploaded this week"}
-
+
- {loading &&
Loading leaderboard...
}
+ {loading &&
Loading leaderboard...
}
{!loading && error &&
{error}
}
{!loading && !error && entries.length === 0 && (
-
No contributors yet.
+
No contributors yet.
)}
{!loading && !error && first && second && third && (
@@ -148,7 +148,7 @@ export default function LeaderboardPage() {
{second.avatar}
-
+
2
{second.name.split(" ")[0]}
{second.uploads} uploads
@@ -158,7 +158,7 @@ export default function LeaderboardPage() {
{first.avatar}
-
+
1
{first.name.split(" ")[0]}
{first.uploads} uploads
@@ -168,7 +168,7 @@ export default function LeaderboardPage() {
{third.avatar}
-
+
3
{third.name.split(" ")[0]}
{third.uploads} uploads
@@ -183,7 +183,7 @@ export default function LeaderboardPage() {
{entries.slice(listStartIndex).map((user, index) => (
(1);
const [classes, setClasses] = useState
([]);
@@ -93,6 +104,15 @@ export default function UploadPage() {
string | null
>(null);
+ const [isDepartmentRequestOpen, setIsDepartmentRequestOpen] = useState(false);
+ const [departmentRequest, setDepartmentRequest] =
+ useState(emptyDepartmentRequest);
+ const [departmentRequestStatus, setDepartmentRequestStatus] =
+ useState("idle");
+ const [departmentRequestMessage, setDepartmentRequestMessage] = useState<
+ string | null
+ >(null);
+
useEffect(() => {
(async () => {
const { session, error } = await getSessionWithRecovery(supabase);
@@ -227,6 +247,64 @@ export default function UploadPage() {
}
};
+ const openDepartmentRequest = () => {
+ setDepartmentRequest(emptyDepartmentRequest);
+ setDepartmentRequestStatus("idle");
+ setDepartmentRequestMessage(null);
+ setIsDepartmentRequestOpen(true);
+ };
+ const closeDepartmentRequest = () => setIsDepartmentRequestOpen(false);
+ const handleDepartmentRequestChange =
+ (field: keyof DepartmentRequestForm) =>
+ (event: React.ChangeEvent) => {
+ setDepartmentRequest((prev) => ({ ...prev, [field]: event.target.value }));
+ };
+ const handleDepartmentRequestSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ setDepartmentRequestMessage(null);
+ if (!accessToken) {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Not authenticated. Please sign in again.");
+ return;
+ }
+ const name = departmentRequest.departmentName.trim();
+ if (!name) {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Department name is required.");
+ return;
+ }
+ setDepartmentRequestStatus("submitting");
+ try {
+ const res = await fetch("/api/department-submissions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({
+ department_name: name,
+ justification: departmentRequest.justification.trim() || null,
+ }),
+ });
+ if (!res.ok) {
+ const payload = await res.json().catch(() => null);
+ const message =
+ payload && typeof payload === "object" && "error" in payload
+ ? String(payload.error)
+ : "Failed to submit the request.";
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage(message);
+ return;
+ }
+ setDepartmentRequestStatus("success");
+ setDepartmentRequestMessage("Request submitted. We will review it soon.");
+ setDepartmentRequest(emptyDepartmentRequest);
+ } catch {
+ setDepartmentRequestStatus("error");
+ setDepartmentRequestMessage("Failed to submit the request. Try again.");
+ }
+ };
+
useEffect(() => {
if (!tokenLoaded || !accessToken || !department.trim()) {
setClasses([]);
@@ -464,8 +542,10 @@ export default function UploadPage() {
✓
-
Upload successful
-
Redirecting you to your dashboard…
+
Upload request received
+
+ Your note will be reviewed by a moderator before it appears in Browse. Redirecting you to your dashboard…
+
);
@@ -562,6 +642,16 @@ export default function UploadPage() {
))}
+
+ Don't see your department?{" "}
+
+ Request a department
+
+
@@ -973,6 +1063,91 @@ export default function UploadPage() {
,
document.body
)}
+
+ {isDepartmentRequestOpen &&
+ typeof document !== "undefined" &&
+ createPortal(
+
+
event.stopPropagation()}
+ >
+
+
+ Request a new department
+
+
+ x
+
+
+
+
+
,
+ document.body
+ )}
);
}
diff --git a/frontend/lib/textextractor.ts b/frontend/lib/textextractor.ts
new file mode 100644
index 0000000..42cc773
--- /dev/null
+++ b/frontend/lib/textextractor.ts
@@ -0,0 +1,149 @@
+/**
+ * TEXTEXTRACTOR — Extract text from PDFs (typed and handwritten/scanned).
+ * Uses typed text layer first (pdfjs-dist); falls back to OCR (Tesseract) when text is missing or minimal.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { fromPath } from "pdf2pic";
+import * as pdfjsLib from "pdfjs-dist";
+
+export type ExtractResult = {
+ text: string;
+ method: "typed" | "ocr";
+ pagesProcessed: number;
+ totalPages: number;
+ /** Only set when method is "ocr" */
+ ocrConfidence?: number;
+};
+
+export type ExtractOptions = {
+ /** Max pages to run OCR on when falling back (default 10). */
+ maxOcrPages?: number;
+ /** Min chars per page (average) to consider typed extraction sufficient (default 20). */
+ minCharsPerPage?: number;
+ /** If true, skip typed extraction and run OCR only (e.g. for known scanned docs). */
+ forceOcr?: boolean;
+};
+
+const DEFAULT_MAX_OCR_PAGES = 10;
+const DEFAULT_MIN_CHARS_PER_PAGE = 20;
+
+/**
+ * Extract text from PDF buffer using the native text layer (typed/digital PDFs) via pdfjs-dist.
+ */
+export async function extractTyped(buffer: Buffer): Promise<{ text: string; numPages: number }> {
+ const uint8 = new Uint8Array(buffer);
+ const doc = await pdfjsLib.getDocument({ data: uint8 }).promise;
+ const numPages = doc.numPages;
+ const parts: string[] = [];
+ for (let i = 1; i <= numPages; i++) {
+ const page = await doc.getPage(i);
+ const content = await page.getTextContent();
+ const pageText = content.items
+ .map((item) => ("str" in item ? item.str : ""))
+ .join(" ");
+ parts.push(pageText);
+ }
+ const text = parts.join("\n\n").trim();
+ return { text, numPages };
+}
+
+/**
+ * Render PDF pages to images and run Tesseract OCR. Requires pdfPath on disk (pdf2pic uses path).
+ */
+export async function extractOcr(
+ pdfPath: string,
+ maxPages: number = DEFAULT_MAX_OCR_PAGES
+): Promise<{ text: string; pagesProcessed: number; confidence?: number }> {
+ const { createWorker } = await import("tesseract.js");
+ const worker = await createWorker("eng", undefined, { logger: () => {} });
+
+ try {
+ const convert = fromPath(pdfPath, { density: 200 });
+ const parts: string[] = [];
+ let pagesProcessed = 0;
+ let totalConfidence = 0;
+
+ for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
+ const result = await convert(pageNum, { format: "png" });
+ if (!result?.path || !fs.existsSync(result.path)) break;
+
+ try {
+ const {
+ data: { text, confidence },
+ } = await worker.recognize(result.path);
+ if (text?.trim()) {
+ parts.push(text.trim());
+ totalConfidence += confidence ?? 0;
+ }
+ pagesProcessed++;
+ } finally {
+ try {
+ fs.unlinkSync(result.path);
+ } catch {
+ // ignore cleanup errors
+ }
+ }
+ }
+
+ const text = parts.join("\n\n");
+ const confidence =
+ pagesProcessed > 0 ? totalConfidence / pagesProcessed : undefined;
+ return { text, pagesProcessed, confidence };
+ } finally {
+ await worker.terminate();
+ }
+}
+
+/**
+ * Extract text from a PDF buffer. Tries typed extraction first; if the result is
+ * too short (likely scanned/handwritten), falls back to OCR for up to maxOcrPages.
+ */
+export async function extract(
+ buffer: Buffer,
+ options: ExtractOptions = {}
+): Promise
{
+ const {
+ maxOcrPages = DEFAULT_MAX_OCR_PAGES,
+ minCharsPerPage = DEFAULT_MIN_CHARS_PER_PAGE,
+ forceOcr = false,
+ } = options;
+
+ if (!forceOcr) {
+ const { text: typedText, numPages } = await extractTyped(buffer);
+ const avgCharsPerPage =
+ numPages > 0 ? typedText.length / numPages : typedText.length;
+ if (typedText.length > 0 && avgCharsPerPage >= minCharsPerPage) {
+ return {
+ text: typedText,
+ method: "typed",
+ pagesProcessed: numPages,
+ totalPages: numPages,
+ };
+ }
+ }
+
+ // Fallback: write buffer to temp file and run OCR (pdf2pic needs a path)
+ const tmpDir = os.tmpdir();
+ const tmpPdf = path.join(tmpDir, `textextractor-${Date.now()}-${Math.random().toString(36).slice(2)}.pdf`);
+ fs.writeFileSync(tmpPdf, buffer);
+ try {
+ const { text, pagesProcessed, confidence } = await extractOcr(tmpPdf, maxOcrPages);
+ const typedMeta = await extractTyped(buffer);
+ return {
+ text: text || typedMeta.text,
+ method: "ocr",
+ pagesProcessed,
+ totalPages: typedMeta.numPages,
+ ocrConfidence: confidence,
+ };
+ } finally {
+ try {
+ fs.unlinkSync(tmpPdf);
+ } catch {
+ // ignore
+ }
+ }
+}
diff --git a/frontend/next.config.ts b/frontend/next.config.ts
index e9ffa30..19df44b 100644
--- a/frontend/next.config.ts
+++ b/frontend/next.config.ts
@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "**.supabase.co",
+ pathname: "/storage/v1/object/sign/**",
+ },
+ ],
+ },
};
export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a0219a7..1b8b925 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,11 +10,14 @@
"dependencies": {
"@supabase/supabase-js": "^2.83.0",
"next": "16.0.3",
+ "pdf2pic": "^3.2.0",
"pdfjs-dist": "4.8.69",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-pdf": "9.1.0",
- "resend": "^6.9.2"
+ "resend": "^6.9.2",
+ "sharp": "^0.34.5",
+ "tesseract.js": "^5.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -773,7 +776,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -3335,6 +3337,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/array-parallel": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz",
+ "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==",
+ "license": "MIT"
+ },
+ "node_modules/array-series": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz",
+ "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==",
+ "license": "MIT"
+ },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
@@ -3674,6 +3688,12 @@
"readable-stream": "^3.4.0"
}
},
+ "node_modules/bmp-js": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
+ "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -4107,7 +4127,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -4318,7 +4337,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -5608,6 +5626,31 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/gm": {
+ "version": "1.25.1",
+ "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.1.tgz",
+ "integrity": "sha512-jgcs2vKir9hFogGhXIfs0ODhJTfIrbECCehg38tqFgHm8zqXx7kAJyCYAFK4jTjx71AxrkFtkJBawbAxYUPX9A==",
+ "deprecated": "The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained",
+ "license": "MIT",
+ "dependencies": {
+ "array-parallel": "~0.1.3",
+ "array-series": "~0.1.5",
+ "cross-spawn": "^7.0.5",
+ "debug": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/gm/node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -5784,6 +5827,12 @@
"node": ">=10.17.0"
}
},
+ "node_modules/idb-keyval": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
+ "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
+ "license": "Apache-2.0"
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -6068,6 +6117,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-electron": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
+ "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==",
+ "license": "MIT"
+ },
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -6312,6 +6367,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-url": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
+ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
+ "license": "MIT"
+ },
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -6369,7 +6430,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -7811,7 +7871,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/nan": {
@@ -7987,7 +8046,6 @@
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
- "optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@@ -8219,6 +8277,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/opencollective-postinstall": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
+ "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
+ "license": "MIT",
+ "bin": {
+ "opencollective-postinstall": "index.js"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -8353,7 +8420,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8376,6 +8442,22 @@
"node": ">=6"
}
},
+ "node_modules/pdf2pic": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/pdf2pic/-/pdf2pic-3.2.0.tgz",
+ "integrity": "sha512-p0bp+Mp4iJy2hqSCLvJ521rDaZkzBvDFT9O9Y0BUID3I04/eDaebAFM5t8hoWeo2BCf42cDijLCGJWTOtkJVpA==",
+ "license": "MIT",
+ "dependencies": {
+ "gm": "^1.25.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "paypal",
+ "url": "https://www.paypal.me/yakovmeister"
+ }
+ },
"node_modules/pdfjs-dist": {
"version": "4.8.69",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.8.69.tgz",
@@ -8893,6 +8975,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerator-runtime": {
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT"
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -9225,7 +9313,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -9269,7 +9356,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -9281,7 +9367,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -9294,7 +9379,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -9888,6 +9972,31 @@
"license": "ISC",
"optional": true
},
+ "node_modules/tesseract.js": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz",
+ "integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bmp-js": "^0.1.0",
+ "idb-keyval": "^6.2.0",
+ "is-electron": "^2.2.2",
+ "is-url": "^1.2.4",
+ "node-fetch": "^2.6.9",
+ "opencollective-postinstall": "^2.0.3",
+ "regenerator-runtime": "^0.13.3",
+ "tesseract.js-core": "^5.1.1",
+ "wasm-feature-detect": "^1.2.11",
+ "zlibjs": "^0.3.1"
+ }
+ },
+ "node_modules/tesseract.js-core": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz",
+ "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -9981,8 +10090,7 @@
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT",
- "optional": true
+ "license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
@@ -10349,19 +10457,23 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/wasm-feature-detect": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
+ "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause",
- "optional": true
+ "license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
- "optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
@@ -10371,7 +10483,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
- "dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -10611,6 +10722,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zlibjs": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
+ "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 6a14b47..bee8b29 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -15,11 +15,14 @@
"dependencies": {
"@supabase/supabase-js": "^2.83.0",
"next": "16.0.3",
+ "pdf2pic": "^3.2.0",
"pdfjs-dist": "4.8.69",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-pdf": "9.1.0",
- "resend": "^6.9.2"
+ "resend": "^6.9.2",
+ "sharp": "^0.34.5",
+ "tesseract.js": "^5.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 3a13f90..027805a 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -24,6 +24,7 @@
},
"include": [
"next-env.d.ts",
+ "**/*.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
diff --git a/frontend/types/pdf2pic.d.ts b/frontend/types/pdf2pic.d.ts
new file mode 100644
index 0000000..681a6e6
--- /dev/null
+++ b/frontend/types/pdf2pic.d.ts
@@ -0,0 +1,18 @@
+declare module "pdf2pic" {
+ type ConvertOptions = {
+ format?: "png" | "jpeg";
+ };
+
+ type FromPathOptions = {
+ density?: number;
+ };
+
+ type ConvertResult = {
+ path?: string;
+ };
+
+ export function fromPath(
+ pdfPath: string,
+ options?: FromPathOptions,
+ ): (page: number, options?: ConvertOptions) => Promise;
+}
diff --git a/frontend/untitled.1.png b/frontend/untitled.1.png
new file mode 100644
index 0000000..91bc525
Binary files /dev/null and b/frontend/untitled.1.png differ
diff --git a/package-lock.json b/package-lock.json
index 0ab5882..e8ad8ba 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2,5 +2,684 @@
"name": "note-sharer",
"lockfileVersion": 3,
"requires": true,
- "packages": {}
+ "packages": {
+ "": {
+ "name": "note-sharer",
+ "dependencies": {
+ "pdf2pic": "^3.2.0",
+ "sharp": "^0.34.5"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/array-parallel": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz",
+ "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==",
+ "license": "MIT"
+ },
+ "node_modules/array-series": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz",
+ "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+ "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.1"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/gm": {
+ "version": "1.25.1",
+ "resolved": "https://registry.npmjs.org/gm/-/gm-1.25.1.tgz",
+ "integrity": "sha512-jgcs2vKir9hFogGhXIfs0ODhJTfIrbECCehg38tqFgHm8zqXx7kAJyCYAFK4jTjx71AxrkFtkJBawbAxYUPX9A==",
+ "deprecated": "The gm module has been sunset. Please migrate to an alternative. https://github.com/aheckmann/gm?tab=readme-ov-file#2025-02-24-this-project-is-not-maintained",
+ "license": "MIT",
+ "dependencies": {
+ "array-parallel": "~0.1.3",
+ "array-series": "~0.1.5",
+ "cross-spawn": "^7.0.5",
+ "debug": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pdf2pic": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/pdf2pic/-/pdf2pic-3.2.0.tgz",
+ "integrity": "sha512-p0bp+Mp4iJy2hqSCLvJ521rDaZkzBvDFT9O9Y0BUID3I04/eDaebAFM5t8hoWeo2BCf42cDijLCGJWTOtkJVpA==",
+ "license": "MIT",
+ "dependencies": {
+ "gm": "^1.25.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "type": "paypal",
+ "url": "https://www.paypal.me/yakovmeister"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ }
+ }
}
diff --git a/package.json b/package.json
index 0448bff..925eba5 100644
--- a/package.json
+++ b/package.json
@@ -8,5 +8,9 @@
"test": "npm --prefix frontend run test",
"format": "npm --prefix frontend run format",
"gitcheck": "node setup.mjs --skip-supabase --skip-env --no-start && npm --prefix frontend run lint && npm --prefix frontend run test"
+ },
+ "dependencies": {
+ "pdf2pic": "^3.2.0",
+ "sharp": "^0.34.5"
}
}
diff --git a/supabase/migrations/202411100910_note_sharer_base_schema.sql b/supabase/migrations/202411100910_note_sharer_base_schema.sql
index cb335b2..8cfa520 100644
--- a/supabase/migrations/202411100910_note_sharer_base_schema.sql
+++ b/supabase/migrations/202411100910_note_sharer_base_schema.sql
@@ -262,6 +262,23 @@ begin
raise exception 'Resource % not available for download', p_resource_id;
end if;
+create table if not exists public.department_submissions (
+ id bigserial primary key,
+ submitter_id uuid references auth.users(id) on delete set null,
+ full_name text not null,
+ department_number text not null,
+ created_at timestamptz default now()
+);
+
+alter table public.department_submissions enable row level security;
+
+-- allow any authenticated user to insert; the application populates
+-- submitter_id itself and the client guard prevents unauthenticated access.
+create policy "Allow authenticated users to insert department submissions"
+ on public.department_submissions
+ for insert with check (auth.uid() is not null);
+
+
-- Try voucher first
select id into v_voucher
from public.download_vouchers
diff --git a/supabase/migrations/202602261200_add_department_submissions.sql b/supabase/migrations/202602261200_add_department_submissions.sql
new file mode 100644
index 0000000..2e98d22
--- /dev/null
+++ b/supabase/migrations/202602261200_add_department_submissions.sql
@@ -0,0 +1,25 @@
+-- Department request submissions (mirrors course_submissions pattern)
+create table if not exists public.department_submissions (
+ id bigserial primary key,
+ submitter_id uuid references public.profiles on delete set null,
+ department_name text not null,
+ justification text,
+ status text not null default 'pending' check (status in ('pending','approved','rejected')),
+ reviewer_id uuid references public.profiles on delete set null,
+ reviewed_at timestamptz,
+ created_at timestamptz not null default now()
+);
+
+alter table public.department_submissions enable row level security;
+
+create policy "Department submissions by owner or moderator" on public.department_submissions
+for select using (
+ auth.uid() = submitter_id
+ or exists (
+ select 1 from public.user_roles
+ where user_roles.profile_id = auth.uid() and user_roles.role in ('admin','moderator','teacher','ta')
+ )
+);
+
+create policy "Insert department submission" on public.department_submissions
+for insert with check (auth.uid() = submitter_id);
diff --git a/supabase/migrations/202602261400_department_submissions_add_name_column.sql b/supabase/migrations/202602261400_department_submissions_add_name_column.sql
new file mode 100644
index 0000000..a605f8f
--- /dev/null
+++ b/supabase/migrations/202602261400_department_submissions_add_name_column.sql
@@ -0,0 +1,23 @@
+-- Ensure department_submissions has department_name (schema cache may be out of sync)
+alter table public.department_submissions
+ add column if not exists department_name text;
+
+-- Backfill from department_code if that column exists and department_name is null
+do $$
+begin
+ if exists (
+ select 1 from information_schema.columns
+ where table_schema = 'public'
+ and table_name = 'department_submissions'
+ and column_name = 'department_code'
+ ) then
+ update public.department_submissions
+ set department_name = coalesce(department_name, department_code, '')
+ where department_name is null;
+ end if;
+end $$;
+
+-- Make department_name not null (use '' for any remaining nulls)
+update public.department_submissions set department_name = '' where department_name is null;
+alter table public.department_submissions
+ alter column department_name set not null;
diff --git a/supabase/migrations/202602261500_department_submissions_add_justification.sql b/supabase/migrations/202602261500_department_submissions_add_justification.sql
new file mode 100644
index 0000000..d47bf46
--- /dev/null
+++ b/supabase/migrations/202602261500_department_submissions_add_justification.sql
@@ -0,0 +1,3 @@
+-- Ensure department_submissions has justification column
+alter table public.department_submissions
+ add column if not exists justification text;
diff --git a/supabase/migrations/202602271200_add_extracted_text_to_resources.sql b/supabase/migrations/202602271200_add_extracted_text_to_resources.sql
new file mode 100644
index 0000000..2bab0b2
--- /dev/null
+++ b/supabase/migrations/202602271200_add_extracted_text_to_resources.sql
@@ -0,0 +1,14 @@
+-- Add column to store text extracted from PDFs (typed layer or OCR) for search.
+alter table public.resources
+ add column if not exists extracted_text text;
+
+-- Include extracted_text in full-text search so search can match note content.
+drop index if exists public.idx_resources_fts;
+create index idx_resources_fts on public.resources using gin (
+ to_tsvector(
+ 'english',
+ coalesce(title, '') || ' ' || coalesce(description, '') || ' ' || coalesce(extracted_text, '')
+ )
+);
+
+comment on column public.resources.extracted_text is 'Text extracted from the PDF (typed layer or OCR) for full-text and keyword search.';