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
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from "fs/promises";
import path from "path";
import { NextRequest, NextResponse } from "next/server";
import { resolveAttachmentFilesystemPath } from "@/lib/content/attachment-path";

const MIME_TYPES: Record<string, string> = {
".webp": "image/webp",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
};

interface RouteContext {
params: Promise<{
branch: string;
semester: string;
subject: string;
path: string[];
}>;
}

export async function GET(_request: NextRequest, context: RouteContext) {
const {
branch,
semester,
subject,
path: pathSegments,
} = await context.params;
const attachmentPath = pathSegments.map(decodeURIComponent).join("/");
Comment thread
imuniqueshiv marked this conversation as resolved.

const filePath = resolveAttachmentFilesystemPath({
branch,
semester,
subject,
attachmentPath,
});

if (!filePath) {
console.warn(
`[attachments] Invalid attachment path requested: ${branch}/${semester}/${subject}/${attachmentPath}`
);
return NextResponse.json(
{ error: "Invalid attachment path" },
{ status: 400 }
);
}

try {
const fileBuffer = await fs.readFile(filePath);
const extension = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[extension] ?? "application/octet-stream";

return new NextResponse(fileBuffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch (error) {
console.warn(
`[attachments] File not found: ${branch}/${semester}/${subject}/${attachmentPath}`,
error
);
return NextResponse.json(
{ error: "Attachment not found" },
{ status: 404 }
);
}
}
7 changes: 0 additions & 7 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,6 @@ export default function RootLayout({
}) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("theme")||"dark";document.documentElement.classList.remove("light","dark");document.documentElement.classList.add(t);document.documentElement.style.colorScheme=t}catch(e){document.documentElement.classList.add("dark")}})();`,
}}
/>
</head>
<body
className="flex min-h-[100dvh] flex-col overflow-x-hidden"
suppressHydrationWarning
Expand Down
22 changes: 15 additions & 7 deletions app/rgpv/[branch]/[semester]/[subject]/pyqs/[paper]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { notFound } from "next/navigation";
import { FileText, Calendar, Clock, ChevronDown, Sparkles } from "lucide-react";
import { getPYQs } from "@/lib/content";
import GenerateAnswerButton from "@/components/ai/generate-answer-button";
import QuestionAttachments from "@/components/pyqs/question-attachments";
import { buildQuestionWithAttachments } from "@/lib/ai/prompt-builder";
import type { SubQuestion } from "@/types/pyq";
interface PaperPageProps {
params: Promise<{
branch: string;
Expand Down Expand Up @@ -145,12 +148,7 @@ export default async function PaperPage({ params }: PaperPageProps) {
question: {
id: string;
questionNumber: string;
subQuestions?: {
id: string;
label: string;
text: string;
unit: string;
}[];
subQuestions?: SubQuestion[];
},
index: number
) => (
Expand Down Expand Up @@ -197,6 +195,13 @@ export default async function PaperPage({ params }: PaperPageProps) {
)
)}
</div>

<QuestionAttachments
attachments={subQuestion.attachments}
branch={branch}
semester={semester}
subject={subject}
/>
</div>

<div
Expand Down Expand Up @@ -224,7 +229,10 @@ export default async function PaperPage({ params }: PaperPageProps) {

<div className="border-t border-blue-500/15 bg-blue-500/[0.03] px-0 pb-3 pt-2 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2 motion-safe:duration-300 sm:px-5 sm:pb-5 sm:pt-4 dark:bg-blue-500/[0.06]">
<GenerateAnswerButton
question={subQuestion.text}
question={buildQuestionWithAttachments(
subQuestion.text,
subQuestion.attachments
)}
subjectCode={subject.toUpperCase()}
label={subQuestion.label}
compactMobile
Expand Down
91 changes: 91 additions & 0 deletions components/pyqs/attachment-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"use client";

import { memo, useCallback, useState } from "react";
import Image from "next/image";
import { ImageOff } from "lucide-react";

interface AttachmentImageProps {
src: string;
alt: string;
title?: string;
caption?: string;
invalidPath?: boolean;
}

function DiagramUnavailableCard() {
return (
<div
className="flex items-center gap-3 rounded-xl border border-border bg-card p-5 shadow-sm"
role="img"
aria-label="Diagram unavailable"
>
<div
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted"
>
<ImageOff className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">
Diagram unavailable
</p>
</div>
);
}

function AttachmentImageComponent({
src,
alt,
title,
caption,
invalidPath = false,
}: AttachmentImageProps) {
const [hasLoadError, setHasLoadError] = useState(invalidPath);

const handleError = useCallback(() => {
console.warn(`[attachments] Failed to load image: ${src}`);
setHasLoadError(true);
}, [src]);

if (!src || hasLoadError) {
return <DiagramUnavailableCard />;
}

return (
<figure className="w-full max-w-2xl">
{title ? (
<figcaption className="mb-2 text-sm font-semibold text-foreground">
{title}
</figcaption>
) : null}

<div
aria-hidden="true"
className="overflow-hidden rounded-xl border border-border bg-white p-3 shadow-sm"
>
<div className="relative mx-auto w-full">
<Image
src={src}
alt={alt}
width={960}
height={720}
loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 672px"
className="mx-auto h-auto w-full max-w-full object-contain"
onError={handleError}
/>
</div>
</div>
Comment thread
imuniqueshiv marked this conversation as resolved.

{caption ? (
<figcaption className="mt-2 text-xs text-muted-foreground sm:text-sm">
{caption}
</figcaption>
) : null}
</figure>
);
}

const AttachmentImage = memo(AttachmentImageComponent);
AttachmentImage.displayName = "AttachmentImage";

export default AttachmentImage;
130 changes: 130 additions & 0 deletions components/pyqs/question-attachments.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import AttachmentImage from "@/components/pyqs/attachment-image";
import {
attachmentFileExists,
getAttachmentPath,
} from "@/lib/content/attachment-path";
import {
ATTACHMENT_TYPE_IMAGE,
getAttachmentAlt,
getAttachmentCaption,
getAttachmentTitle,
isQuestionAttachment,
isSupportedAttachmentType,
} from "@/lib/content/attachment-utils";
import type { QuestionAttachment } from "@/types/pyq";

interface QuestionAttachmentsProps {
attachments?: QuestionAttachment[];
branch: string;
semester: string;
subject: string;
}

function warnInDevelopment(message: string) {
if (process.env.NODE_ENV === "development") {
console.warn(message);
}
}

async function renderAttachment(
attachment: QuestionAttachment,
branch: string,
semester: string,
subject: string
) {
if (!isQuestionAttachment(attachment)) {
warnInDevelopment(
`[attachments] Skipping malformed attachment in ${subject}: ${JSON.stringify(attachment)}`
);
return null;
}

if (!isSupportedAttachmentType(attachment.type)) {
warnInDevelopment(
`[attachments] Unsupported attachment type "${attachment.type}" for ${attachment.id}`
);
return null;
}

const pathInput = {
branch,
semester,
subject,
attachmentPath: attachment.path,
};

const src = getAttachmentPath(pathInput);

if (!src) {
console.warn(
`[attachments] Invalid attachment path for ${attachment.id}: ${attachment.path}`
);

return (
<AttachmentImage
key={attachment.id}
src=""
alt={getAttachmentAlt(attachment)}
title={getAttachmentTitle(attachment)}
caption={getAttachmentCaption(attachment)}
invalidPath
/>
);
}

const fileExists = await attachmentFileExists(pathInput);

if (!fileExists) {
console.warn(
`[attachments] Attachment file missing for ${attachment.id}: ${attachment.path}`
);
}

switch (attachment.type) {
case ATTACHMENT_TYPE_IMAGE:
return (
<AttachmentImage
key={attachment.id}
src={src}
alt={getAttachmentAlt(attachment)}
title={getAttachmentTitle(attachment)}
caption={getAttachmentCaption(attachment)}
invalidPath={!fileExists}
/>
);
default:
warnInDevelopment(
`[attachments] No renderer for attachment type "${attachment.type}"`
);
return null;
}
}

export default async function QuestionAttachments({
attachments,
branch,
semester,
subject,
}: QuestionAttachmentsProps) {
if (!attachments?.length) {
return null;
}

const renderedAttachments = (
await Promise.all(
attachments.map((attachment) =>
renderAttachment(attachment, branch, semester, subject)
)
)
).filter(Boolean);

if (!renderedAttachments.length) {
return null;
}

return (
<div className="space-y-4" aria-label="Question attachments">
{renderedAttachments}
</div>
);
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading