Skip to content

Commit 831a6b2

Browse files
Merge branch 'staging' into prod
2 parents c530573 + 90d791e commit 831a6b2

24 files changed

Lines changed: 1281 additions & 243 deletions

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 CodeChef-VIT
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@types/node": "^24.6.1",
3131
"@ungap/with-resolvers": "^0.1.0",
3232
"@upstash/ratelimit": "^2.0.5",
33+
"@upstash/redis": "^1.33.0",
3334
"@vercel/kv": "^3.0.0",
3435
"axios": "^1.8.4",
3536
"canvas": "^3.2.0",

pnpm-lock.yaml

Lines changed: 91 additions & 59 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/actions/get-papers-by-id.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ export const fetchPaperID = async (id: string): Promise<PaperResponse> => {
66

77
try {
88
const response: AxiosResponse<PaperResponse> = await axios.get(
9-
`${serverUrl}/api/paper-by-id/${id}`
9+
`${serverUrl}/api/paper-by-id/${id}`,
1010
);
1111
return response.data;
1212
} catch (err: unknown) {
1313
if (axios.isAxiosError(err)) {
1414
console.error("Axios error:", err.response?.data ?? err.message);
15-
const errorMessage = (err.response?.data as { message?: string })?.message ?? "Failed to fetch paper";
15+
const errorMessage =
16+
(err.response?.data as { message?: string })?.message ??
17+
"Failed to fetch paper";
1618
throw new Error(errorMessage);
1719
} else {
1820
console.error("Unexpected error:", err);

src/app/api/report-tag/route.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { NextResponse } from "next/server";
2+
import { connectToDatabase } from "@/lib/database/mongoose";
3+
import TagReport from "@/db/tagReport";
4+
import { Ratelimit } from "@upstash/ratelimit";
5+
import { redis } from "@/lib/utils/redis";
6+
import { exams } from "@/components/select_options";
7+
8+
interface ReportedFieldInput {
9+
field: string;
10+
value?: string;
11+
}
12+
const ALLOWED_FIELDS = ["subject", "courseCode", "exam", "slot", "year"];
13+
14+
const ratelimit = new Ratelimit({
15+
redis,
16+
limiter: Ratelimit.slidingWindow(3, "1 h"),//per id - 3 request - per hour
17+
analytics: true,
18+
});
19+
20+
function getClientIp(req: Request & { ip?: string}): string {
21+
return req.ip || "127.0.0.1";
22+
}
23+
24+
export async function POST(req: Request & { ip?: string }) {
25+
try {
26+
await connectToDatabase();
27+
28+
const body = await req.json();
29+
const { paperId } = body;
30+
31+
if (!paperId) {
32+
return NextResponse.json(
33+
{ error: "paperId is required" },
34+
{ status: 400 }
35+
);
36+
}
37+
const ip = getClientIp(req);
38+
const key = `${ip}::${paperId}`;
39+
const { success } = await ratelimit.limit(key);
40+
41+
if (!success) {
42+
return NextResponse.json(
43+
{ error: "Rate limit exceeded for reporting." },
44+
{ status: 429 }
45+
);
46+
}
47+
const MAX_REPORTS_PER_PAPER = 5;
48+
const count = await TagReport.countDocuments({ paperId });
49+
50+
if (count >= MAX_REPORTS_PER_PAPER) {
51+
return NextResponse.json(
52+
{ error: "Received many reports; we are currently working on it." },
53+
{ status: 429 }
54+
);
55+
}
56+
const reportedFields: ReportedFieldInput[] = Array.isArray(body.reportedFields)
57+
? body.reportedFields
58+
.map((r:Partial<ReportedFieldInput>) => ({
59+
field: typeof r.field === "string" ? r.field.trim() : "",
60+
value: typeof r.value === "string" ? r.value.trim() : undefined,
61+
}))
62+
.filter((r:Partial<ReportedFieldInput>) => r.field)
63+
: [];
64+
65+
for (const rf of reportedFields) {
66+
if (!ALLOWED_FIELDS.includes(rf.field)) {
67+
return NextResponse.json(
68+
{ error: `Invalid field: ${rf.field}` },
69+
{ status: 400 }
70+
);
71+
}
72+
if (rf.field === "exam" && rf.value) {
73+
if (!exams.some(e => e.toLowerCase() === rf.value?.toLowerCase())) {
74+
return NextResponse.json(
75+
{ error: `Invalid exam value: ${rf.value}` },
76+
{ status: 400 }
77+
);
78+
}
79+
}
80+
}
81+
82+
const newReport = await TagReport.create({
83+
paperId,
84+
reportedFields,
85+
comment: body.comment,
86+
reporterEmail: body.reporterEmail,
87+
reporterId: body.reporterId,
88+
});
89+
90+
return NextResponse.json(
91+
{ message: "Report submitted.", report: newReport },
92+
{ status: 201 }
93+
);
94+
} catch (err) {
95+
console.error(err);
96+
return NextResponse.json(
97+
{ error: "Failed to submit tag report." },
98+
{ status: 500 }
99+
);
100+
}
101+
}

src/app/paper/[id]/page.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { extractBracketContent } from "@/lib/utils/string";
77
import axios, { type AxiosResponse } from "axios";
88
import { type Metadata } from "next";
99
import { redirect } from "next/navigation";
10+
import { PaperProvider } from "@/context/PaperContext";
1011

1112
export async function generateMetadata({
1213
params,
@@ -164,10 +165,20 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
164165
</div>
165166
</h1>
166167
<center>
168+
<PaperProvider
169+
value={{
170+
paperId: params.id,
171+
subject: paper.subject,
172+
exam: paper.exam,
173+
slot: paper.slot,
174+
year: paper.year,
175+
}}
176+
>
167177
<PdfViewer
168178
url={paper.file_url}
169179
name={`${extractBracketContent(paper.subject)}-${paper.exam}-${paper.slot}-${paper.year}`}
170180
></PdfViewer>
181+
</PaperProvider>
171182
</center>
172183
<RelatedPapers />
173184
</>

src/app/upload/page.tsx

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,23 @@ export default function Page() {
136136
return;
137137
}
138138

139+
const totalSize = allFiles.reduce(
140+
(sum, f) => sum + f.size,
141+
0,
142+
);
143+
if (totalSize > maxFileSize){
144+
toast.error("The total upload size exceeds 5MB.", { id: toastId });
145+
return;
146+
}
147+
139148
const invalidFiles = acceptedFiles.filter(
140149
(file) =>
141150
file.size > maxFileSize || !allowedFileTypes.includes(file.type),
142151
);
143152

144153
if (invalidFiles.length > 0) {
145154
toast.error(
146-
"Some files are invalid. Make sure each is under 5MB and of allowed types (PDF, JPEG, PNG, GIF).",
155+
"Some files are invalid. Make sure the total size is below 5MB and files are of allowed types (PDF, JPEG, PNG, GIF).",
147156
{ id: toastId },
148157
);
149158
return;
@@ -375,44 +384,52 @@ export default function Page() {
375384
multiple={true}
376385
>
377386
{({ getRootProps, getInputProps, isDragActive }) => {
378-
const pdfUploaded = files.some(f => f.type === "application/pdf");
379-
return(
380-
<div
381-
className={`relative h-20 w-20 flex-shrink-0 touch-none group${
382-
isDragActive || isGlobalDragging
383-
? "border-2 border-solid border-[#6D28D9]"
384-
: ""
385-
}`}
386-
{...(!pdfUploaded ? getRootProps() : {})}
387-
>
388-
{!pdfUploaded && <input {...getInputProps()} />}
389-
<div className={`absolute left-4 top-4 h-16 w-16 rounded-2xl bg-violet-950 ${pdfUploaded ? "text-gray-500 cursor-not-allowed" : "text-white cursor-pointer"}`} />
390-
<div className="absolute left-0 top-0 h-10 w-10 rounded-[20px] bg-violet-950" />
391-
<div className="absolute left-1 top-1 flex h-8 w-8 items-center rounded-[20px] bg-black/50" />
392-
<div className={`absolute left-9 top-9 text-2xl ${pdfUploaded ? "text-gray-500 cursor-not-allowed" : "text-white cursor-pointer"}`}
393-
>
394-
<div className={`absolute text-2xl ${pdfUploaded ? "text-gray-500 cursor-not-allowed" : "text-white cursor-pointer"}`}
395-
>
396-
<FiPlus className="h-7 w-7" />
397-
398-
{pdfUploaded && (<div className="absolute left-12 top-1/2 -translate-y-1/2 whitespace-nowrap rounded-md bg-gradient-to-r from-indigo-900 to-violet-900 px-3 py-1 text-xs text-white shadow-lg opacity-0 group-hover:opacity-100 transition-all duration-300 group-hover:translate-x-1">
399-
Only one PDF file is permitted.
387+
const pdfUploaded = files.some(
388+
(f) => f.type === "application/pdf",
389+
);
390+
return (
391+
<div
392+
className={`relative h-20 w-20 flex-shrink-0 touch-none group${
393+
isDragActive || isGlobalDragging
394+
? "border-2 border-solid border-[#6D28D9]"
395+
: ""
396+
}`}
397+
{...(!pdfUploaded ? getRootProps() : {})}
398+
>
399+
{!pdfUploaded && <input {...getInputProps()} />}
400+
<div
401+
className={`absolute left-4 top-4 h-16 w-16 rounded-2xl bg-[#A78BFA] dark:bg-violet-950 ${pdfUploaded ? "cursor-not-allowed text-gray-500" : "cursor-pointer text-white"}`}
402+
/>
403+
<div className="absolute left-0 top-0 h-10 w-10 rounded-[20px] bg-[#A78BFA] dark:bg-violet-950" />
404+
405+
<div className="absolute left-1 top-1 flex h-8 w-8 items-center rounded-[20px] bg-black/30 dark:bg-black/50" />
406+
<div
407+
className={`absolute left-9 top-9 text-2xl ${pdfUploaded ? "cursor-not-allowed text-gray-500" : "cursor-pointer text-white"}`}
408+
>
409+
<div
410+
className={`absolute text-2xl ${pdfUploaded ? "cursor-not-allowed text-gray-500" : "cursor-pointer text-white"}`}
411+
>
412+
<FiPlus className="h-7 w-7" />
413+
414+
{pdfUploaded && (
415+
<div className="absolute left-12 top-1/2 -translate-y-1/2 whitespace-nowrap rounded-md bg-gradient-to-r from-indigo-900 to-violet-900 px-3 py-1 text-xs text-white opacity-0 shadow-lg transition-all duration-300 group-hover:translate-x-1 group-hover:opacity-100">
416+
Only one PDF file is permitted.
417+
</div>
418+
)}
419+
</div>
420+
</div>
421+
<div className="absolute left-4 top-3 text-xs font-semibold text-white">
422+
{previews.length}
400423
</div>
401-
)}
402-
</div>
403-
</div>
404-
<div className="absolute left-4 top-3 text-xs font-semibold text-white">
405-
{previews.length}
406424
</div>
407-
</div>
408425
);
409426
}}
410427
</Dropzone>
411428
)}
412429
{previews.length > 0 && (
413430
<section className="mt-6 flex w-full flex-col items-center">
414431
<div className="flex w-max gap-4">
415-
<div className="scrollbar-hide flex w-[80vw] max-w-4xl flex-col justify-between overflow-x-auto overflow-y-hidden rounded-[40px] border-[6px] border-indigo-900 bg-indigo-900/10 p-4 sm:p-6 md:w-max md:p-8">
432+
<div className="scrollbar-hide flex w-[80vw] max-w-4xl flex-col justify-between overflow-x-auto overflow-y-hidden rounded-[40px] border-[6px] border-[#A78BFA] bg-indigo-900/10 p-4 dark:border-indigo-900 sm:p-6 md:w-max md:p-8">
416433
<DndContext
417434
sensors={sensors}
418435
collisionDetection={closestCenter}
@@ -493,7 +510,7 @@ export default function Page() {
493510
<Button
494511
onClick={handleUpload}
495512
disabled={isUploading || files.length === 0}
496-
className="mt-8 rounded-[40px] bg-violet-950 px-8 py-3 text-xl text-white hover:bg-violet-800"
513+
className="mt-8 rounded-[40px] bg-[#A78BFA] px-8 py-3 text-xl text-white hover:bg-[#8B5CF6] dark:bg-violet-950 dark:hover:bg-violet-800"
497514
>
498515
{isUploading ? "Uploading..." : "Upload"}
499516
</Button>

src/components/CatalogueContent.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useCourses } from "@/context/courseContext";
1818
import { FilterProvider, useFilters } from "@/context/filterContext";
1919
import EmptyState from "./ui/EmptyState";
2020
import SidebarButton from "./SidebarButton";
21+
import SortComponent from "./ui/sorting";
2122

2223
const CatalogueContentInner = ({ subject }: { subject: string | null }) => {
2324
const [isMounted, setIsMounted] = useState(false);
@@ -27,6 +28,7 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => {
2728
const [pinned, setPinned] = useState<boolean>(false);
2829
const [relatedSubjects, setRelatedSubjects] = useState<string[]>([]);
2930
const { courses } = useCourses();
31+
const [sortOption, setSortOption] = useState<"asc" | "desc" | "none">("none");
3032

3133
// Use filter context
3234
const {
@@ -175,6 +177,31 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => {
175177
void fetchPapers();
176178
}, [subject, isMounted, setPapers, setFilterOptions]);
177179

180+
useEffect(() => {
181+
if (!papers.length) return;
182+
183+
const filtered = [...papers];
184+
185+
if (sortOption === "asc") {
186+
filtered.sort((a, b) => a.year.localeCompare(b.year));
187+
} else if (sortOption === "desc") {
188+
filtered.sort((a, b) => b.year.localeCompare(a.year));
189+
}
190+
191+
setFilteredPapers(filtered);
192+
}, [
193+
papers,
194+
selectedExams,
195+
selectedSlots,
196+
selectedYears,
197+
selectedSemesters,
198+
selectedCampuses,
199+
selectedAnswerKeyIncluded,
200+
sortOption,
201+
setFilteredPapers,
202+
setAppliedFilters,
203+
]);
204+
178205
useEffect(() => {
179206
if (!papers.length) return;
180207

@@ -206,6 +233,11 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => {
206233
answerkeyCondition
207234
);
208235
});
236+
if (sortOption === "asc") {
237+
filtered.sort((a, b) => a.year.localeCompare(b.year));
238+
} else if (sortOption === "desc") {
239+
filtered.sort((a, b) => b.year.localeCompare(a.year));
240+
}
209241
setFilteredPapers(filtered);
210242
setAppliedFilters(
211243
selectedExams.length > 0 ||
@@ -284,12 +316,21 @@ const CatalogueContentInner = ({ subject }: { subject: string | null }) => {
284316
</div>
285317

286318
{/* Select/Deselect/Download All Buttons */}
287-
<div className="mb-8 flex w-full items-center justify-end gap-4">
288-
<SidebarButton onClick={handleSelectAll}>Select All</SidebarButton>
289-
<SidebarButton onClick={handleDeselectAll}>
319+
<div className="mb-6 mt-5 flex w-full flex-wrap items-center justify-start gap-3 sm:gap-4 md:mt-4 md:justify-end">
320+
<SortComponent
321+
onSortChange={setSortOption}
322+
currentSort={sortOption}
323+
/>
324+
325+
<SidebarButton onClick={handleSelectAll} className="order-2">
326+
Select All
327+
</SidebarButton>
328+
329+
<SidebarButton onClick={handleDeselectAll} className="order-2">
290330
Deselect All
291331
</SidebarButton>
292-
<SidebarButton onClick={handleDownloadSelected}>
332+
333+
<SidebarButton onClick={handleDownloadSelected} className="order-2">
293334
Download Selected
294335
</SidebarButton>
295336
</div>

0 commit comments

Comments
 (0)