From 27e63f6a96147449a0be3e17b622444a4429b99a Mon Sep 17 00:00:00 2001 From: Rithish Date: Thu, 30 Jul 2026 18:49:44 +0000 Subject: [PATCH 1/2] Fix sidebar , scroll , render issues --- src/components/SideBar.tsx | 4 +++- src/components/newPdfViewer.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index a3b535b8..486aadda 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -100,7 +100,9 @@ function SideBar() { ]; return ( -
+
diff --git a/src/components/newPdfViewer.tsx b/src/components/newPdfViewer.tsx index a2c47ab8..fb47466c 100644 --- a/src/components/newPdfViewer.tsx +++ b/src/components/newPdfViewer.tsx @@ -105,7 +105,7 @@ const Controls = memo(function Controls({documentId, toggleFullscreen, isFullscr onFocus={() => setPageNo("")} className="h-9 w-14 rounded border bg-[#e7e9ff] p-1 text-center text-sm [appearance:textfield] dark:bg-[#1f1f2a] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" /> - of {totalPages ?? 1} + {totalPages > 0 ? `of ${totalPages}` : "loading…"}
) From e7bd4974dcf88c41e91a0a2faa903b273b09dde3 Mon Sep 17 00:00:00 2001 From: Rithish Date: Fri, 31 Jul 2026 07:33:22 +0000 Subject: [PATCH 2/2] for issues #463 #498 #474 #476 #461 #454 --- .../ui/announcement/Announcement.tsx | 321 ++++++++++++++++++ src/context/filterContext.tsx | 27 +- 2 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 src/components/ui/announcement/Announcement.tsx diff --git a/src/components/ui/announcement/Announcement.tsx b/src/components/ui/announcement/Announcement.tsx new file mode 100644 index 00000000..9afd4f11 --- /dev/null +++ b/src/components/ui/announcement/Announcement.tsx @@ -0,0 +1,321 @@ +"use client"; + +import * as React from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { X, Megaphone, ArrowUpRight, type LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type AnnouncementAccent = "purple" | "amber" | "green" | "rose"; +export type AnnouncementVariant = "banner" | "card"; + +export interface AnnouncementProps { + /** + * Stable, unique id for this announcement (e.g. "cookoff-2026"). + * Used as the localStorage key so a dismissal is remembered per-campaign, + * not globally — dismissing one promo doesn't hide future ones. + */ + id: string; + title: string; + message: string; + variant?: AnnouncementVariant; + /** Call-to-action label, e.g. "Register now". Omit to render without a CTA. */ + ctaLabel?: string; + /** Where the CTA (and, for banners, the whole strip) links to. External links open in a new tab automatically. */ + href?: string; + /** Card-variant only: image shown above the copy (16:9). Ignored for banners. */ + imageUrl?: string; + imageAlt?: string; + /** Custom lucide icon; defaults to a megaphone. */ + icon?: LucideIcon; + /** Color preset. Pick based on urgency/category, not vibes: purple = general promo, amber = time-sensitive, green = opportunity/open, rose = urgent/closing soon. */ + accent?: AnnouncementAccent; + /** Whether the person can dismiss it. Defaults to true. */ + dismissible?: boolean; + /** ISO date string. Past this date the announcement stops rendering, dismissed or not — no stale promos. */ + expiresAt?: string; + /** Shows a small "Sponsored" tag for transparency. Defaults to true — this is an ad, say so. */ + sponsored?: boolean; + onDismiss?: () => void; + className?: string; +} + +const accentStyles: Record< + AnnouncementAccent, + { + cardBorder: string; + cardSurface: string; + bannerSurface: string; + chip: string; + chipIcon: string; + accentText: string; + cta: string; + tagText: string; + } +> = { + purple: { + cardBorder: "border-[#734DFF] dark:border-[#36266D]", + cardSurface: "bg-white dark:bg-[#171720]", + bannerSurface: "bg-[#EFEAFF] dark:bg-[#171720]", + chip: "bg-[#EFEAFF] dark:bg-[#262635]", + chipIcon: "text-[#562EE7] dark:text-[#A79DFF]", + accentText: "text-[#562EE7] dark:text-[#A79DFF]", + cta: "bg-[#734DFF] text-white hover:bg-[#5F3FE0]", + tagText: "text-[#734DFF] dark:text-[#A79DFF]", + }, + amber: { + cardBorder: "border-[#d97706] dark:border-[#7c4a08]", + cardSurface: "bg-white dark:bg-[#171720]", + bannerSurface: "bg-[#fef3c7] dark:bg-[#3a2a05]", + chip: "bg-[#fde8b8] dark:bg-[#4a3608]", + chipIcon: "text-[#92600b] dark:text-[#f5c451]", + accentText: "text-[#92600b] dark:text-[#f5c451]", + cta: "bg-[#d97706] text-white hover:bg-[#b8630a]", + tagText: "text-[#92600b] dark:text-[#f5c451]", + }, + green: { + cardBorder: "border-[#16a34a] dark:border-[#0f6b32]", + cardSurface: "bg-white dark:bg-[#171720]", + bannerSurface: "bg-[#dcfce7] dark:bg-[#052e13]", + chip: "bg-[#c7f7d4] dark:bg-[#0b3d1c]", + chipIcon: "text-[#15803d] dark:text-[#6ee7a0]", + accentText: "text-[#15803d] dark:text-[#6ee7a0]", + cta: "bg-[#16a34a] text-white hover:bg-[#128a3e]", + tagText: "text-[#15803d] dark:text-[#6ee7a0]", + }, + rose: { + cardBorder: "border-[#e11d48] dark:border-[#8a0f2c]", + cardSurface: "bg-white dark:bg-[#171720]", + bannerSurface: "bg-[#ffe4e6] dark:bg-[#3f0d16]", + chip: "bg-[#ffd2d6] dark:bg-[#521022]", + chipIcon: "text-[#be123c] dark:text-[#fda4b0]", + accentText: "text-[#be123c] dark:text-[#fda4b0]", + cta: "bg-[#e11d48] text-white hover:bg-[#c11640]", + tagText: "text-[#be123c] dark:text-[#fda4b0]", + }, +}; + +function isExternal(href: string): boolean { + return /^https?:\/\//i.test(href); +} + +function SmartLink({ + href, + className, + children, + ariaLabel, +}: { + href: string; + className?: string; + children: React.ReactNode; + ariaLabel?: string; +}) { + if (isExternal(href)) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} + +/** + * Reusable promo/announcement unit for advertising events, deadlines, or + * sponsors on the papers site. Two variants: + * + * - "banner": a thin dismissible strip, meant for the top of a page. + * - "card": a promo card sized to match `Card.tsx`, meant to sit inside + * the same paper grid (`grid-cols-1 md:grid-cols-2 lg:grid-cols-4`) as + * a native-feeling in-feed ad. + * + * Dismissal is remembered per `id` in localStorage, and an optional + * `expiresAt` retires the announcement automatically so stale promos never + * linger after an event has passed. + */ +export default function Announcement({ + id, + title, + message, + variant = "banner", + ctaLabel, + href, + imageUrl, + imageAlt, + icon: Icon = Megaphone, + accent = "purple", + dismissible = true, + expiresAt, + sponsored = true, + onDismiss, + className, +}: AnnouncementProps) { + const [mounted, setMounted] = React.useState(false); + const [dismissed, setDismissed] = React.useState(false); + + const storageKey = `announcement:${id}:dismissed`; + + const isExpired = React.useMemo(() => { + if (!expiresAt) return false; + const expiry = new Date(expiresAt); + if (Number.isNaN(expiry.getTime())) return false; + return Date.now() > expiry.getTime(); + }, [expiresAt]); + + React.useEffect(() => { + setMounted(true); + if (dismissible) { + try { + setDismissed(window.localStorage.getItem(storageKey) === "true"); + } catch { + setDismissed(false); + } + } + }, [storageKey, dismissible]); + + const handleDismiss = () => { + setDismissed(true); + try { + window.localStorage.setItem(storageKey, "true"); + } catch { + } + onDismiss?.(); + }; + + if (!mounted || isExpired || (dismissible && dismissed)) return null; + + const styles = accentStyles[accent]; + + if (variant === "banner") { + return ( +
+
+
+ +
+ +
+ + {title} + + + {message} + +
+ +
+ {sponsored && ( + + Sponsored + + )} + + {ctaLabel && href && ( + + {ctaLabel} + + + )} + + {dismissible && ( + + )} +
+
+
+ ); + } + + // Card variant — sized to sit inside the same grid as paper Cards. + return ( +
+ {dismissible && ( + + )} + + {imageUrl ? ( + {imageAlt + ) : ( +
+ +
+ )} + +
+
+ {sponsored && ( + + Sponsored + + )} +
{title}
+

{message}

+
+ + {ctaLabel && href && ( + + {ctaLabel} + + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/context/filterContext.tsx b/src/context/filterContext.tsx index 69c643c9..b77ee0f5 100644 --- a/src/context/filterContext.tsx +++ b/src/context/filterContext.tsx @@ -147,17 +147,22 @@ export const FilterProvider: React.FC = ({ new Set(selectedPapers.map((paper) => paper._id)), ).map((id) => selectedPapers.find((paper) => paper._id === id)) as IPaper[]; - for (const paper of uniquePapers) { - try { - console.log(paper); - const response = await fetch(getSecureUrl(paper.file_url)); - const blob = await response.blob(); - const filename = generateFileName(paper); - zip.file(filename, blob); - } catch (err) { - console.error(`Failed to fetch ${paper.file_url}`, err); - } - } + await Promise.all( + uniquePapers.map(async (paper) => { + try { + const response = await fetch(getSecureUrl(paper.file_url)); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const blob = await response.blob(); + const filename = generateFileName(paper); + zip.file(filename, blob); + } catch (err) { + console.error(`Failed to fetch ${paper.file_url}`, err); + } + }), + ); + function getDownloadName( params: ReadonlyURLSearchParams, key: string,