From 2c074b045a661496d83f29ef64e703544384ac05 Mon Sep 17 00:00:00 2001 From: Rithish-2914 Date: Wed, 23 Sep 2026 18:00:07 +0530 Subject: [PATCH] feat: Add floating draggable Syllabus mini-player overlay with PDF viewer --- src/app/api/syllabus/route.ts | 28 ++ src/app/paper/[id]/page.tsx | 2 + src/components/CatalogueContent.tsx | 2 + src/components/SyllabusDock.tsx | 390 ++++++++++++++++++++++++++++ src/db/course.ts | 1 + src/interface.ts | 3 + src/styles/globals.css | 43 +++ 7 files changed, 469 insertions(+) create mode 100644 src/app/api/syllabus/route.ts create mode 100644 src/components/SyllabusDock.tsx diff --git a/src/app/api/syllabus/route.ts b/src/app/api/syllabus/route.ts new file mode 100644 index 00000000..029858a7 --- /dev/null +++ b/src/app/api/syllabus/route.ts @@ -0,0 +1,28 @@ +import { connectToDatabase } from "@/lib/database/mongoose"; +import { Course } from "@/db/course"; +import { success, failure } from "@/lib/utils/response"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const subject = searchParams.get("subject"); + + if (!subject) { + return failure("Subject parameter is required", 400); + } + + await connectToDatabase(); + + const course = await Course.findOne( + { name: subject }, + { syllabus: 1, _id: 0 } + ).lean<{ syllabus?: string } | null>(); + + return success({ syllabus: course?.syllabus ?? null }); + } catch (error) { + console.error("Error fetching syllabus:", error); + return failure("Failed to fetch syllabus", 500); + } +} diff --git a/src/app/paper/[id]/page.tsx b/src/app/paper/[id]/page.tsx index 7e8fba2b..18d9c64e 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 SyllabusDock from "@/components/SyllabusDock"; export async function generateMetadata({ params, @@ -187,6 +188,7 @@ const PaperPage = async ({ params }: { params: { id: string } }) => { + )} diff --git a/src/components/CatalogueContent.tsx b/src/components/CatalogueContent.tsx index 967a8671..eda0cd1f 100644 --- a/src/components/CatalogueContent.tsx +++ b/src/components/CatalogueContent.tsx @@ -19,6 +19,7 @@ import { FilterProvider, useFilters } from "@/context/filterContext"; import EmptyState from "./ui/EmptyState"; import SelectionToolbar from "./SelectionToolbar"; import SortComponent from "./ui/sorting"; +import SyllabusDock from "./SyllabusDock"; const CatalogueContentInner = ({ subject }: { subject: string | null }) => { const [isMounted, setIsMounted] = useState(false); @@ -414,6 +415,7 @@ const CatalogueContent = () => { return ( + ); }; diff --git a/src/components/SyllabusDock.tsx b/src/components/SyllabusDock.tsx new file mode 100644 index 00000000..4a838d64 --- /dev/null +++ b/src/components/SyllabusDock.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import axios from "axios"; +import { type ApiResponse } from "@/interface"; +import { X, Download, Minus, Maximize2, Minimize2 } from "lucide-react"; +import PDFViewer from "./newPdfViewer"; + +interface SyllabusDockProps { + subject: string | null; +} + +type DockState = "hidden" | "collapsed" | "expanded"; + +export default function SyllabusDock({ subject }: SyllabusDockProps) { + const [syllabusUrl, setSyllabusUrl] = useState(null); + const [dockState, setDockState] = useState("hidden"); + const [loading, setLoading] = useState(false); + const [hasAnimated, setHasAnimated] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [position, setPosition] = useState({ right: -1, bottom: -1 }); + const [size, setSize] = useState({ width: 420, height: 600 }); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const dockRef = useRef(null); + + useEffect(() => { + if (typeof window !== "undefined" && position.right === -1) { + const initialWidth = 420; + const initialHeight = Math.min(600, window.innerHeight * 0.7); + setSize({ width: initialWidth, height: initialHeight }); + setPosition({ right: 24, bottom: 24 }); + } + }, [position.right]); + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 768); + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); + }, []); + + const fetchSyllabus = useCallback(async () => { + if (!subject) { + setDockState("hidden"); + setSyllabusUrl(null); + return; + } + + setLoading(true); + try { + const res = await axios.get>( + "/api/syllabus", + { params: { subject } } + ); + const url = res.data.data?.syllabus; + if (url) { + setSyllabusUrl(url); + const savedState = sessionStorage.getItem("syllabus-dock-state"); + setDockState(savedState === "expanded" ? "expanded" : "collapsed"); + setHasAnimated(false); + } else { + setSyllabusUrl(null); + setDockState("collapsed"); + } + } catch { + setSyllabusUrl(null); + setDockState("collapsed"); + } finally { + setLoading(false); + } + }, [subject]); + + useEffect(() => { + void fetchSyllabus(); + }, [fetchSyllabus]); + + useEffect(() => { + if (dockState === "collapsed" && !hasAnimated) { + const timer = setTimeout(() => setHasAnimated(true), 100); + return () => clearTimeout(timer); + } + }, [dockState, hasAnimated]); + + const isDraggingRef = useRef(false); + + const handleMouseDownDrag = (e: React.MouseEvent) => { + if (isFullscreen) return; + + const target = e.target as HTMLElement; + if (target.closest("button")) { + return; + } + + e.preventDefault(); + setIsDragging(true); + isDraggingRef.current = false; + const startX = e.clientX; + const startY = e.clientY; + const startPosRight = position.right; + const startPosBottom = position.bottom; + + let currentRight = startPosRight; + let currentBottom = startPosBottom; + + const onMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + if (Math.abs(dx) > 3 || Math.abs(dy) > 3) { + isDraggingRef.current = true; + } + + const newRight = startPosRight - dx; + const newBottom = startPosBottom - dy; + + const currentWidth = dockState === "collapsed" ? (dockRef.current?.querySelector('.dock-pill')?.clientWidth ?? 160) : size.width; + const currentHeight = dockState === "collapsed" ? (dockRef.current?.querySelector('.dock-pill')?.clientHeight ?? 48) : size.height; + + const maxRight = window.innerWidth - currentWidth; + const maxBottom = window.innerHeight - currentHeight; + + currentRight = Math.max(0, Math.min(maxRight, newRight)); + currentBottom = Math.max(0, Math.min(maxBottom, newBottom)); + + setPosition({ right: currentRight, bottom: currentBottom }); + }; + + const onMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + const handleMouseDownResize = (e: React.MouseEvent) => { + if (isFullscreen) return; + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + + const startX = e.clientX; + const startY = e.clientY; + const startW = size.width; + const startH = size.height; + const startPosRight = position.right; + const startPosBottom = position.bottom; + + const onMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + + const newWidth = Math.max(300, startW + dx); + const newHeight = Math.max(200, startH + dy); + + setSize({ + width: newWidth, + height: newHeight, + }); + setPosition({ + right: startPosRight - (newWidth - startW), + bottom: startPosBottom - (newHeight - startH) + }); + }; + + const onMouseUp = () => { + setIsDragging(false); + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + + const handlePillClick = () => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + return; + } + if (syllabusUrl) { + setDockState("expanded"); + sessionStorage.setItem("syllabus-dock-state", "expanded"); + + setPosition(prev => ({ + right: Math.max(0, Math.min(window.innerWidth - size.width, prev.right)), + bottom: Math.max(0, Math.min(window.innerHeight - size.height, prev.bottom)) + })); + } + }; + + const handleCollapse = () => { + setDockState("collapsed"); + sessionStorage.setItem("syllabus-dock-state", "collapsed"); + }; + + const handleClose = () => { + setDockState("collapsed"); + sessionStorage.setItem("syllabus-dock-state", "collapsed"); + + setPosition({ right: 24, bottom: 24 }); + }; + + const subjectName = subject?.split(" [")[0] ?? ""; + const courseCode = subject?.split("[")[1]?.replace("]", "") ?? ""; + + if (dockState === "hidden" || position.right === -1) return null; + + const getPositionStyle = () => { + if (isMobile) return {}; + + if (isFullscreen && dockState === "expanded") { + return { left: 0, top: 0, width: "100vw", height: "100vh", borderRadius: 0 }; + } + + if (dockState === "collapsed") { + return { + right: `${position.right}px`, + bottom: `${position.bottom}px` + }; + } + + return { + right: `${position.right}px`, + bottom: `${position.bottom}px`, + width: `${size.width}px`, + height: `${size.height}px` + }; + }; + + if (dockState === "collapsed") { + const pillStyle = isMobile ? { bottom: '24px', right: '24px' } : getPositionStyle(); + + return ( +
+
+ + {syllabusUrl ? "View Syllabus" : "No Syllabus"} + + {syllabusUrl && } +
+
+ ); + } + + if (isMobile) { + return ( +
+
+
+

+ {courseCode} +

+

+ {subjectName} +

+
+
+ + + + +
+
+ +
+ {loading ? ( +
+
+
+ ) : ( + + )} +
+
+ ); + } + + return ( +
+
+
+

+ {courseCode} +

+

+ {subjectName} — Syllabus +

+
+
e.stopPropagation()}> + + + +
+
+ +
+ {loading ? ( +
+
+
+ ) : ( +
+ +
+ )} +
+ +
+ + Syllabus PDF + + +
+ + {!isFullscreen && ( +
+ )} +
+ ); +} diff --git a/src/db/course.ts b/src/db/course.ts index 942f2d4d..862d2e39 100644 --- a/src/db/course.ts +++ b/src/db/course.ts @@ -3,6 +3,7 @@ import { type ICourseCount, type ICourses } from "@/interface"; const courseSchema = new Schema({ name: { type: String, required: true }, + syllabus: { type: String, required: false }, }); const courseCountSchema = new Schema({ diff --git a/src/interface.ts b/src/interface.ts index 03b3c938..d8a8223b 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -60,6 +60,7 @@ export interface IAdminPaper { export interface ICourses { name: string; + syllabus?: string; } export interface APIResponse { @@ -153,10 +154,12 @@ export interface ICourseCount { export interface ICourse { _id: string; name: string; + syllabus?: string; } export interface ICourseWithCount { _id: string; name: string; count: number; + syllabus?: string; } diff --git a/src/styles/globals.css b/src/styles/globals.css index 6dfef59a..0159b82f 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -177,3 +177,46 @@ body { display: none; } } + +/* Syllabus Dock Animations */ +.dock-pill-enter { + opacity: 0; + transform: translateY(20px) scale(0.9); +} + +.dock-pill-visible { + opacity: 1; + transform: translateY(0) scale(1); + transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), + transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); +} + +.dock-pill:hover { + transform: translateY(-2px) scale(1.02); +} + +.dock-panel { + animation: dock-expand 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes dock-expand { + from { + opacity: 0; + transform: translateY(40px) scale(0.92); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.dock-loader { + animation: dock-spin 0.8s linear infinite; +} + +@keyframes dock-spin { + to { + transform: rotate(360deg); + } +} +