Skip to content
Open
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
34 changes: 34 additions & 0 deletions src/app/api/paper-counts/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 10 additions & 1 deletion src/app/paper/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -159,11 +160,19 @@ const PaperPage = async ({ params }: { params: { id: string } }) => {
</div>
) : (
<>
<h1 className="my-6 flex justify-center gap-4 text-center font-play text-2xl font-semibold md:mb-10 md:text-3xl">
<h1 className="mt-6 flex justify-center gap-4 text-center font-play text-2xl font-semibold md:text-3xl">
<div>
{paper.subject} {paper.exam} {paper.slot} {paper.year}
</div>
</h1>
<div className="mb-6 mt-2 text-center md:mb-10">
<PaperCountsLine
paperId={params.id}
views={paper.views}
downloads={paper.downloads}
countView
/>
</div>
<center>
<PaperProvider
value={{
Expand Down
12 changes: 11 additions & 1 deletion src/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { Capsule } from "@/components/ui/capsule";
import { cn } from "@/lib/utils";
import PDFViewer from "@/components/newPdfViewer";
import { PaperProvider } from "@/context/PaperContext";
import PaperCountsLine from "@/components/PaperCountsLine";
import { sendPaperActivity } from "@/lib/utils/paper-counts";

interface CardProps {
paper: IPaper;
Expand Down Expand Up @@ -51,7 +53,8 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => {
}, [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 = () => {
Expand Down Expand Up @@ -102,6 +105,12 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => {
<Capsule>{paper.semester}</Capsule>
{paper.school && <Capsule>{paper.school}</Capsule>}
</div>
<PaperCountsLine
paperId={paper._id}
views={paper.views}
downloads={paper.downloads}
className="block"
/>
</div>
</div>

Expand All @@ -111,6 +120,7 @@ const Card = ({ paper, onSelect, isSelected, isShow=true }: CardProps) => {
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
void sendPaperActivity("view", [paper._id]);
}}
/>

Expand Down
37 changes: 37 additions & 0 deletions src/components/PaperCountsLine.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();

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 (
<span className={cn("font-play text-sm opacity-70", className)}>
{describeCount(counts.views, "view")} · {describeCount(counts.downloads, "download")}
</span>
);
}
8 changes: 6 additions & 2 deletions src/components/newPdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/context/filterContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -162,6 +163,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({
).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.
Expand All @@ -186,6 +188,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({
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);
Expand Down Expand Up @@ -221,6 +224,7 @@ export const FilterProvider: React.FC<FilterProviderProps> = ({
a.click();
a.remove();
URL.revokeObjectURL(url);
void sendPaperActivity("download", zippedPaperIds);

posthog.capture("papers_bulk_downloaded", {
download_count: uniquePapers.length - failedCount,
Expand Down
2 changes: 2 additions & 0 deletions src/db/papers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const paperSchema = new Schema<IPaper>({
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<IAdminPaper> =
Expand Down
9 changes: 9 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface PaperResponse {
slot: string;
exam: string;
school?: string;
views?: number;
downloads?: number;
}

export interface IAdminPaper {
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions src/lib/services/paper-counts.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, PaperCounts>> {
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 },
]),
);
}
4 changes: 3 additions & 1 deletion src/lib/utils/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const generateFileName = (paper: IPaper): string => {
export const downloadFile = async (
url: string,
filename: string,
): Promise<void> => {
): Promise<boolean> => {
try {
const response = await axios.get(url, { responseType: "blob" });
const blob = new Blob([response.data]);
Expand All @@ -23,12 +23,14 @@ 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) {
toast.error("You are offline. Please check your internet connection.");
} else {
toast.error("This paper is no longer available on the server.");
}
return false;
}
};
44 changes: 44 additions & 0 deletions src/lib/utils/paper-counts.ts
Original file line number Diff line number Diff line change
@@ -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<ApiResponse<Record<string, PaperCounts>>>(
"/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<Record<string, PaperCounts>>).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"}`;
}
2 changes: 1 addition & 1 deletion src/lib/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()??"";
Expand Down