Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions frontend/app/api/course-submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
13 changes: 10 additions & 3 deletions frontend/app/api/notes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,11 @@ export async function GET(req: Request) {
const previewPaths = Array.from(
new Set(
pageRows
.map((row) => row.preview_key ?? row.file_key)
.map((row) =>
row.preview_key && !row.preview_key.toLowerCase().endsWith(".pdf")
? row.preview_key
: null,
)
.filter((path): path is string => Boolean(path)),
),
);
Expand All @@ -204,7 +208,10 @@ export async function GET(req: Request) {
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 previewPath =
row.preview_key && !row.preview_key.toLowerCase().endsWith(".pdf")
? row.preview_key
: null;

return {
id: row.id,
Expand All @@ -220,7 +227,7 @@ 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,
previewUrl: previewPath ? previewUrlMap.get(previewPath) ?? null : null,
};
});

Expand Down
3 changes: 2 additions & 1 deletion frontend/app/api/profile/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -122,7 +123,7 @@ export async function GET() {
totalUpvotes,
creditsEarned,
creditsSpent,
netCredits: normalizeNetCredits(profileData?.credit_score),
netCredits: normalizeNetCredits(profileCreditScore),
},
rank: {
allTime: rank,
Expand Down
22 changes: 22 additions & 0 deletions frontend/app/api/upload/helpers/preview.ts
Original file line number Diff line number Diff line change
@@ -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<Buffer> {
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;
}
80 changes: 71 additions & 9 deletions frontend/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ 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 { generateBlurredFirstPageBuffer } from "./helpers/preview";

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",
Expand Down Expand Up @@ -33,23 +39,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;
}
};

Expand Down Expand Up @@ -172,7 +208,33 @@ 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);
}
}

const { data: resource, error: insertError } = await supabase
.from("resources")
.insert({
Expand All @@ -182,7 +244,7 @@ export async function POST(req: NextRequest) {
resource_type: resourceType,
description: description || null,
file_key: filePath,
preview_key: filePath,
preview_key: previewKey,
})
.select()
.single();
Expand Down
14 changes: 9 additions & 5 deletions frontend/app/dashboard/course/[classId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
import { useParams } from "next/navigation";
import Link from "next/link";
import { getSessionWithRecovery, supabase } from "@/lib/supabaseClient";
import PDFThumbnail from "@/app/components/pdf/PDFThumbnail";
import { DesignNav } from "@/app/components/DesignNav";
import ProfileIcons from "../../profile-icon";
import { getCourseSubline } from "../../course-name-utils";
import "../../dashboard.css";
import "../../browse.css";
import "../../course-detail.css";
import Image from "next/image";


type CourseOption = {
id: string;
Expand Down Expand Up @@ -753,15 +754,18 @@ export default function CourseDetailPage() {
<div className="note-modal-content">
<div className="note-modal-preview">
{selectedNote.previewUrl ? (
<PDFThumbnail
fileUrl={selectedNote.previewUrl}
width={400}
<Image
src={selectedNote.previewUrl}
alt="Note preview"
className="note-modal-preview-img"
width={1200}
height={1600}
/>
) : (
<div className="note-modal-no-preview">
No preview available
</div>
)}
)}
</div>
<div className="note-modal-details">
<p>
Expand Down
14 changes: 9 additions & 5 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1110,13 +1110,17 @@ body {
overflow: hidden;
border: 1px solid #e2e8f0;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}

.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: 100%;
object-fit: cover;
display: block;
}

.note-modal-no-preview {
Expand Down
10 changes: 9 additions & 1 deletion frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading