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…"}
)
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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {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,