diff --git a/src/app/api/paper-counts/route.ts b/src/app/api/paper-counts/route.ts
new file mode 100644
index 00000000..16729926
--- /dev/null
+++ b/src/app/api/paper-counts/route.ts
@@ -0,0 +1,34 @@
+import { Types } from "mongoose";
+import { z } from "zod";
+import { countPaperActivity } from "@/lib/services/paper-counts";
+import { getClientIp } from "@/lib/utils/rate-limiter";
+import { success, failure } from "@/lib/utils/response";
+
+export const dynamic = "force-dynamic";
+
+const paperActivity = z.object({
+ kind: z.enum(["view", "download"]),
+ paperIds: z
+ .array(z.string().refine((id) => Types.ObjectId.isValid(id)))
+ .min(1)
+ .max(100),
+});
+
+export async function POST(req: Request) {
+ try {
+ const body: unknown = await req.json().catch(() => null);
+ const activity = paperActivity.safeParse(body);
+
+ if (!activity.success) {
+ return failure("send a kind of view or download and up to 100 paper ids", 400);
+ }
+
+ const { kind, paperIds } = activity.data;
+ const counts = await countPaperActivity(kind, [...new Set(paperIds)], getClientIp(req));
+
+ return success(counts);
+ } catch (error) {
+ console.error(error);
+ return failure("could not update the paper counts", 500);
+ }
+}
diff --git a/src/app/paper/[id]/page.tsx b/src/app/paper/[id]/page.tsx
index 7e8fba2b..b56ac7a9 100644
--- a/src/app/paper/[id]/page.tsx
+++ b/src/app/paper/[id]/page.tsx
@@ -8,6 +8,7 @@ import { type Metadata } from "next";
import { redirect } from "next/navigation";
import { PaperProvider } from "@/context/PaperContext";
import PDFViewer from "@/components/newPdfViewer";
+import PaperCountsLine from "@/components/PaperCountsLine";
export async function generateMetadata({
params,
@@ -159,11 +160,19 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
) : (
<>
-
+
{paper.subject} {paper.exam} {paper.slot} {paper.year}
+
{
}, [previewOpen]);
const handleDownload = async (paper: IPaper) => {
- await downloadFile(getSecureUrl(paper.file_url), generateFileName(paper));
+ const downloaded = await downloadFile(getSecureUrl(paper.file_url), generateFileName(paper));
+ if (downloaded) void sendPaperActivity("download", [paper._id]);
};
const handleCheckboxChange = () => {
@@ -102,6 +105,12 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => {
{paper.semester}
{paper.school && {paper.school}}
+
@@ -111,6 +120,7 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => {
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
+ void sendPaperActivity("view", [paper._id]);
}}
/>
diff --git a/src/components/PaperCountsLine.tsx b/src/components/PaperCountsLine.tsx
new file mode 100644
index 00000000..d8f6906a
--- /dev/null
+++ b/src/components/PaperCountsLine.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { describeCount, sendPaperActivity, usePaperCounts } from "@/lib/utils/paper-counts";
+
+const viewedOnThisPage = new Set();
+
+interface PaperCountsLineProps {
+ paperId: string;
+ views?: number;
+ downloads?: number;
+ countView?: boolean;
+ className?: string;
+}
+
+export default function PaperCountsLine({
+ paperId,
+ views = 0,
+ downloads = 0,
+ countView = false,
+ className,
+}: PaperCountsLineProps) {
+ const counts = usePaperCounts(paperId, { views, downloads });
+
+ useEffect(() => {
+ if (!countView || viewedOnThisPage.has(paperId)) return;
+ viewedOnThisPage.add(paperId);
+ void sendPaperActivity("view", [paperId]);
+ }, [countView, paperId]);
+
+ return (
+
+ {describeCount(counts.views, "view")} ยท {describeCount(counts.downloads, "download")}
+
+ );
+}
diff --git a/src/components/newPdfViewer.tsx b/src/components/newPdfViewer.tsx
index 68ead11f..2c1aa643 100644
--- a/src/components/newPdfViewer.tsx
+++ b/src/components/newPdfViewer.tsx
@@ -17,6 +17,8 @@ import { Button } from "./ui/button";
import ShareButton from "./ShareButton";
import ReportButton from "./ReportButton";
import { useTheme } from "next-themes";
+import { sendPaperActivity } from "@/lib/utils/paper-counts";
+import { usePaper } from "@/context/PaperContext";
interface ControlProps {
documentId: string;
@@ -461,6 +463,7 @@ export default function PDFViewer({
onReadingModeChange,
}: PdfViewerProps) {
const { engine, isLoading } = usePdfiumEngine();
+ const { paperId } = usePaper();
const { isMobile, isSmall } = useBreakpoint();
const { resolvedTheme } = useTheme();
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -495,8 +498,9 @@ export default function PDFViewer({
posthog.capture("paper_downloaded", {
paper_name: name,
});
- await downloadFile(url, `${name}.pdf`);
- }, [url, name]);
+ const downloaded = await downloadFile(url, `${name}.pdf`);
+ if (downloaded) void sendPaperActivity("download", [paperId]);
+ }, [url, name, paperId]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
diff --git a/src/context/filterContext.tsx b/src/context/filterContext.tsx
index 77548ae8..8de7a752 100644
--- a/src/context/filterContext.tsx
+++ b/src/context/filterContext.tsx
@@ -18,6 +18,7 @@ import posthog from "posthog-js";
import JSZip from "jszip";
import { toast } from "react-hot-toast";
import { getSecureUrl, generateFileName } from "@/lib/utils/download";
+import { sendPaperActivity } from "@/lib/utils/paper-counts";
interface FilterState {
selectedExams: string[];
@@ -162,6 +163,7 @@ export const FilterProvider: React.FC = ({
).map((id) => selectedPapers.find((paper) => paper._id === id)) as IPaper[];
let failedCount = 0;
+ const zippedPaperIds: string[] = [];
// Different papers can share a generated name (e.g. same subject/exam/slot/year
// from different campuses). JSZip overwrites entries with the same path, so
// append " (1)", " (2)", ... to keep every paper in the zip.
@@ -186,6 +188,7 @@ export const FilterProvider: React.FC = ({
const blob = await response.blob();
const filename = getUniqueName(generateFileName(paper));
zip.file(filename, blob);
+ zippedPaperIds.push(paper._id);
} catch (err) {
failedCount += 1;
console.error(`Failed to fetch ${paper.file_url}`, err);
@@ -221,6 +224,7 @@ export const FilterProvider: React.FC = ({
a.click();
a.remove();
URL.revokeObjectURL(url);
+ void sendPaperActivity("download", zippedPaperIds);
posthog.capture("papers_bulk_downloaded", {
download_count: uniquePapers.length - failedCount,
diff --git a/src/db/papers.ts b/src/db/papers.ts
index 3d1fb039..6b2016c7 100644
--- a/src/db/papers.ts
+++ b/src/db/papers.ts
@@ -78,6 +78,8 @@ const paperSchema = new Schema({
enum: ["SCOPE","SITE","SENSE","SMEC","SCE","SELECT","SAS","SSL","VITBS","SBST","SCORE","SCHEME","SHINE","V-SIGN","V-SMART","V-SPARC","VAIAL","HOT"],
},
answer_key_included: { type: Boolean, default: false },
+ views: { type: Number, default: 0 },
+ downloads: { type: Number, default: 0 },
});
export const PaperAdmin: Model =
diff --git a/src/interface.ts b/src/interface.ts
index 03b3c938..895a3937 100644
--- a/src/interface.ts
+++ b/src/interface.ts
@@ -18,6 +18,8 @@ export interface PaperResponse {
slot: string;
exam: string;
school?: string;
+ views?: number;
+ downloads?: number;
}
export interface IAdminPaper {
@@ -122,6 +124,13 @@ export interface IPaper {
subject: string;
year: string;
answer_key_included?: boolean;
+ views?: number;
+ downloads?: number;
+}
+
+export interface PaperCounts {
+ views: number;
+ downloads: number;
}
export interface Filters {
diff --git a/src/lib/services/paper-counts.ts b/src/lib/services/paper-counts.ts
new file mode 100644
index 00000000..4ffb68b7
--- /dev/null
+++ b/src/lib/services/paper-counts.ts
@@ -0,0 +1,54 @@
+import { Ratelimit } from "@upstash/ratelimit";
+import Paper from "@/db/papers";
+import { type PaperCounts } from "@/interface";
+import { connectToDatabase } from "@/lib/database/mongoose";
+import { redis } from "@/lib/utils/redis";
+
+export type PaperActivity = "view" | "download";
+
+const countedField = { view: "views", download: "downloads" } as const;
+
+const upstashConfigured = Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN);
+
+const oncePerHour =
+ redis && upstashConfigured
+ ? new Ratelimit({ redis, limiter: Ratelimit.fixedWindow(1, "1 h"), prefix: "paper-counts" })
+ : null;
+
+async function firstThisHour(key: string) {
+ if (!oncePerHour) return true;
+ const { success } = await oncePerHour.limit(key);
+ return success;
+}
+
+export async function countPaperActivity(
+ kind: PaperActivity,
+ paperIds: string[],
+ visitor: string,
+): Promise> {
+ await connectToDatabase();
+
+ const firstTimes = await Promise.all(
+ paperIds.map((paperId) => firstThisHour(`${visitor}::${kind}::${paperId}`)),
+ );
+ const newlyCounted = paperIds.filter((_, index) => firstTimes[index]);
+
+ if (newlyCounted.length > 0) {
+ await Paper.updateMany(
+ { _id: { $in: newlyCounted } },
+ { $inc: { [countedField[kind]]: 1 } },
+ );
+ }
+
+ const papers = await Paper.find(
+ { _id: { $in: paperIds } },
+ { views: 1, downloads: 1 },
+ ).lean();
+
+ return Object.fromEntries(
+ papers.map((paper) => [
+ String(paper._id),
+ { views: paper.views ?? 0, downloads: paper.downloads ?? 0 },
+ ]),
+ );
+}
diff --git a/src/lib/utils/download.ts b/src/lib/utils/download.ts
index 5d02d102..538a8499 100644
--- a/src/lib/utils/download.ts
+++ b/src/lib/utils/download.ts
@@ -14,7 +14,7 @@ export const generateFileName = (paper: IPaper): string => {
export const downloadFile = async (
url: string,
filename: string,
-): Promise => {
+): Promise => {
try {
const response = await axios.get(url, { responseType: "blob" });
const blob = new Blob([response.data]);
@@ -23,6 +23,7 @@ export const downloadFile = async (
link.download = filename;
link.click();
window.URL.revokeObjectURL(link.href);
+ return true;
} catch (error) {
console.error("Download failed (File missing or network error):", error);
if (typeof navigator !== "undefined" && !navigator.onLine) {
@@ -30,5 +31,6 @@ export const downloadFile = async (
} else {
toast.error("This paper is no longer available on the server.");
}
+ return false;
}
};
\ No newline at end of file
diff --git a/src/lib/utils/paper-counts.ts b/src/lib/utils/paper-counts.ts
new file mode 100644
index 00000000..cde48a57
--- /dev/null
+++ b/src/lib/utils/paper-counts.ts
@@ -0,0 +1,44 @@
+import axios from "axios";
+import { useEffect, useState } from "react";
+import { type ApiResponse, type PaperCounts } from "@/interface";
+import { chunkArray } from "@/lib/utils/array";
+
+const countsChanged = "paperCountsChanged";
+
+export async function sendPaperActivity(kind: "view" | "download", paperIds: string[]) {
+ for (const batch of chunkArray(paperIds, 100)) {
+ try {
+ const response = await axios.post>>(
+ "/api/paper-counts",
+ { kind, paperIds: batch },
+ );
+ const counts = response.data.data;
+ if (counts) {
+ window.dispatchEvent(new CustomEvent(countsChanged, { detail: counts }));
+ }
+ } catch {
+ return;
+ }
+ }
+}
+
+export function usePaperCounts(paperId: string, initial: PaperCounts) {
+ const [counts, setCounts] = useState(initial);
+
+ useEffect(() => {
+ const takeFresh = (event: Event) => {
+ const fresh = (event as CustomEvent>).detail[paperId];
+ if (fresh) setCounts(fresh);
+ };
+ window.addEventListener(countsChanged, takeFresh);
+ return () => window.removeEventListener(countsChanged, takeFresh);
+ }, [paperId]);
+
+ return counts;
+}
+
+const compact = new Intl.NumberFormat("en", { notation: "compact" });
+
+export function describeCount(count: number, word: string) {
+ return `${compact.format(count)} ${word}${count === 1 ? "" : "s"}`;
+}
diff --git a/src/lib/utils/rate-limiter.ts b/src/lib/utils/rate-limiter.ts
index bef77232..6f349a51 100644
--- a/src/lib/utils/rate-limiter.ts
+++ b/src/lib/utils/rate-limiter.ts
@@ -2,7 +2,7 @@ import { redis } from "@/lib/utils/redis";
import { Ratelimit } from "@upstash/ratelimit";
import { CustomError } from "./error";
-function getClientIp(req: Request & { ip?: string}): string {
+export function getClientIp(req: Request & { ip?: string}): string {
const xff = req.headers.get("x-forwarded-for");
if (typeof xff === "string" && xff.length > 0) {
return xff.split(",")[0]?.trim()??"";