Repository Public
1/*2 research-explorer.tsx3 spboucher.ai Web4 Author: Simon-Pierre Boucher5 Mail: contact@spboucher.ai6*/78"use client";910import * as React from "react";11import Link from "next/link";12import {13 ArrowUpRight,14 BadgeCheck,15 ExternalLink,16 FileText,17 Github,18 ListFilter,19 Search,20 Users,21} from "lucide-react";2223import { Badge } from "@/components/ui/badge";24import { cn } from "@/lib/utils";25import type {26 ResearchField,27 ResearchFieldInfo,28 ResearchPaper,29} from "@/lib/research";3031type SortKey = "newest" | "oldest" | "longest" | "score";3233interface ResearchExplorerProps {34 papers: ResearchPaper[];35 fields: ResearchFieldInfo[];36}3738const fieldTone: Record<ResearchField, string> = {39 "real-estate":40 "border-emerald-700/25 bg-emerald-600/8 text-emerald-800 dark:border-emerald-300/25 dark:bg-emerald-300/10 dark:text-emerald-200",41 finance:42 "border-primary/25 bg-primary/8 text-primary",43 ai: "border-fuchsia-700/25 bg-fuchsia-600/8 text-fuchsia-800 dark:border-fuchsia-300/25 dark:bg-fuchsia-300/10 dark:text-fuchsia-200",44 methods:45 "border-amber-700/25 bg-amber-600/8 text-amber-800 dark:border-amber-300/25 dark:bg-amber-300/10 dark:text-amber-200",46 math: "border-rose-700/25 bg-rose-600/8 text-rose-800 dark:border-rose-300/25 dark:bg-rose-300/10 dark:text-rose-200",47};4849function matches(paper: ResearchPaper, q: string) {50 if (!q) return true;51 const hay = [52 paper.num,53 paper.title,54 paper.subtitle ?? "",55 paper.description,56 ...(paper.authors ?? []),57 ]58 .join(" ")59 .toLowerCase();60 return q61 .toLowerCase()62 .split(/\s+/)63 .filter(Boolean)64 .every((term) => hay.includes(term));65}6667export function ResearchExplorer({ papers, fields }: ResearchExplorerProps) {68 const [field, setField] = React.useState<ResearchField | "all">("all");69 const [query, setQuery] = React.useState("");70 const [sort, setSort] = React.useState<SortKey>("newest");7172 const counts = React.useMemo(() => {73 const c: Record<string, number> = { all: papers.length };74 for (const p of papers) if (p.field) c[p.field] = (c[p.field] ?? 0) + 1;75 return c;76 }, [papers]);7778 const visible = React.useMemo(() => {79 const list = papers.filter(80 (p) => (field === "all" || p.field === field) && matches(p, query),81 );82 const n = (p: ResearchPaper) => p.number ?? 0;83 switch (sort) {84 case "oldest":85 return [...list].sort(86 (a, b) => n(a) - n(b),87 );88 case "longest":89 return [...list].sort(90 (a, b) => (b.pageCount ?? 0) - (a.pageCount ?? 0),91 );92 case "score":93 return [...list].sort(94 (a, b) => (b.review?.scoreAfter ?? 0) - (a.review?.scoreAfter ?? 0),95 );96 default:97 return [...list].sort(98 (a, b) => n(b) - n(a),99 );100 }101 }, [papers, field, query, sort]);102103 const activeField = fields.find((f) => f.id === field);104 const fieldNames = fields.map((f) => f.label.toLowerCase().replace("ai ", "AI "));105 const fieldList =106 fieldNames.length > 1107 ? `${fieldNames.slice(0, -1).join(", ")} and ${fieldNames[fieldNames.length - 1]}`108 : (fieldNames[0] ?? "");109110 return (111 <div>112 {/* Toolbar */}113 <div className="sticky top-16 z-30 -mx-4 border-y border-border/70 bg-background/92 px-4 py-3 backdrop-blur-xl sm:-mx-6 sm:px-6">114 <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">115 <div116 className="flex flex-wrap gap-1.5"117 role="tablist"118 aria-label="Filter by field"119 >120 {[{ id: "all" as const, short: "All" }, ...fields].map((f) => {121 const active = field === f.id;122 return (123 <button124 key={f.id}125 type="button"126 role="tab"127 aria-selected={active}128 onClick={() => setField(f.id)}129 className={cn(130 "inline-flex items-center gap-1.5 rounded-[0.5rem] border px-2.5 py-1.5 font-mono text-[0.68rem] font-medium uppercase tracking-[0.12em] transition-all",131 active132 ? "border-foreground bg-foreground text-background shadow-[var(--shadow-soft)]"133 : "border-border bg-card text-muted-foreground hover:border-foreground/40 hover:text-foreground",134 )}135 >136 {f.short}137 <span138 className={cn(139 "tabular-nums",140 active ? "text-background/70" : "text-muted-foreground/70",141 )}142 >143 {counts[f.id] ?? 0}144 </span>145 </button>146 );147 })}148 </div>149150 <div className="flex items-center gap-2">151 <label className="relative flex-1 md:w-60">152 <Search153 className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"154 aria-hidden="true"155 />156 <span className="sr-only">Search papers</span>157 <input158 type="search"159 value={query}160 onChange={(e) => setQuery(e.target.value)}161 placeholder="Search titles, topics, authors…"162 className="h-9 w-full rounded-[0.5rem] border border-border bg-card pl-8 pr-3 text-sm text-foreground shadow-[var(--shadow-soft)] placeholder:text-muted-foreground/70 focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-ring/30"163 />164 </label>165 <label className="relative">166 <ListFilter167 className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"168 aria-hidden="true"169 />170 <span className="sr-only">Sort</span>171 <select172 value={sort}173 onChange={(e) => setSort(e.target.value as SortKey)}174 className="h-9 appearance-none rounded-[0.5rem] border border-border bg-card pl-8 pr-8 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-foreground shadow-[var(--shadow-soft)] focus:border-primary/60 focus:outline-none focus:ring-2 focus:ring-ring/30"175 >176 <option value="newest">Newest</option>177 <option value="oldest">Oldest</option>178 <option value="longest">Longest</option>179 <option value="score">Quality score</option>180 </select>181 </label>182 </div>183 </div>184 </div>185186 {/* Field blurb + count */}187 <div className="mt-5 flex flex-wrap items-baseline justify-between gap-2">188 <p className="max-w-2xl text-sm leading-relaxed text-muted-foreground">189 {activeField190 ? activeField.blurb191 : `${papers.length} quality-controlled working papers across ${fieldList} — each presented in its revised version, with its quality-control record, a public code repository and a reproducible pipeline.`}192 </p>193 <p194 className="font-mono text-[0.68rem] uppercase tracking-[0.14em] text-muted-foreground"195 aria-live="polite"196 >197 {visible.length} quality-controlled{" "}198 {visible.length === 1 ? "paper" : "papers"}199 </p>200 </div>201202 {/* Grid */}203 {visible.length === 0 ? (204 <div className="mt-8 border border-dashed border-border px-6 py-12 text-center text-sm text-muted-foreground">205 No paper matches “{query}”. Try a broader term or clear the field filter.206 </div>207 ) : (208 <ul className="mt-6 grid gap-4 md:grid-cols-2">209 {visible.map((paper) => (210 <PaperCard key={paper.slug} paper={paper} fields={fields} />211 ))}212 </ul>213 )}214 </div>215 );216}217218function PaperCard({219 paper,220 fields,221}: {222 paper: ResearchPaper;223 fields: ResearchFieldInfo[];224}) {225 const fieldInfo = fields.find((f) => f.id === paper.field);226 const coauthored = (paper.authors?.length ?? 1) > 1;227 const shortNum = paper.num.replace("UQO Working Paper No. ", "WP ");228 return (229 <li className="group relative flex h-full flex-col rounded-[0.9rem] border border-border bg-card shadow-[var(--shadow-soft)] transition-all duration-300 hover:-translate-y-0.5 hover:border-foreground/25 hover:shadow-[var(--shadow-lift)]">230 <div className="flex flex-1 flex-col p-5 sm:p-6">231 <div className="flex flex-wrap items-center gap-1.5">232 <span className="font-mono text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-foreground">233 {shortNum}234 </span>235 {fieldInfo && paper.field && (236 <span237 className={cn(238 "inline-flex items-center rounded-[0.4rem] border px-2 py-[0.2rem] font-mono text-[0.62rem] font-medium uppercase tracking-[0.1em]",239 fieldTone[paper.field],240 )}241 >242 {fieldInfo.short}243 </span>244 )}245 {paper.status && <Badge variant="outline">{paper.status}</Badge>}246 <span className="ml-auto font-mono text-[0.66rem] uppercase tracking-[0.12em] text-muted-foreground">247 {paper.version && `v${paper.version} · `}248 {paper.date ?? paper.year}249 </span>250 </div>251252 <h3 className="mt-3 font-display text-[1.12rem] font-semibold leading-snug tracking-tight">253 <Link254 href={`/research/${paper.slug}`}255 className="after:absolute after:inset-0 after:rounded-[0.9rem] after:content-[''] focus:outline-none focus-visible:underline"256 >257 {paper.title}258 </Link>259 </h3>260 {paper.subtitle && (261 <p className="mt-1 text-sm italic leading-snug text-foreground/70">262 {paper.subtitle}263 </p>264 )}265 <p className="mt-3 text-sm leading-relaxed text-muted-foreground">266 {paper.description}267 </p>268269 <div className="mt-auto pt-5">270 <div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[0.66rem] uppercase tracking-[0.12em] text-muted-foreground">271 {paper.pages && <span>{paper.pages}</span>}272 {coauthored && (273 <span className="inline-flex items-center gap-1">274 <Users className="h-3 w-3" aria-hidden="true" />275 {paper.authors!.length} authors276 </span>277 )}278 {paper.ssrn && <span>SSRN</span>}279 {paper.review && (280 <span281 className="ml-auto inline-flex items-center gap-1.5 rounded-[0.4rem] border border-foreground/15 bg-background/60 px-2 py-[0.2rem] font-medium text-foreground/85"282 title={`Quality controlled — quality score of the revised version: ${paper.review.scoreAfter.toFixed(1)} / 100`}283 >284 <BadgeCheck285 className="h-3.5 w-3.5 text-primary"286 aria-hidden="true"287 />288 Quality controlled289 <span className="tabular-nums text-foreground">290 {paper.review.scoreAfter.toFixed(1)}291 <span className="text-muted-foreground">/100</span>292 </span>293 </span>294 )}295 </div>296 {/* Action row sits above the stretched card link so each button stays clickable. */}297 <div className="relative z-10 mt-3 flex flex-wrap gap-2">298 <Link299 href={`/research/${paper.slug}`}300 className="inline-flex items-center gap-1.5 rounded-[0.5rem] bg-foreground px-3 py-1.5 text-xs font-medium text-background shadow-[var(--shadow-soft)] transition-all hover:-translate-y-px hover:bg-foreground/85"301 >302 <ArrowUpRight className="h-3.5 w-3.5" aria-hidden="true" />303 Details304 </Link>305 <a306 href={paper.pdf}307 target="_blank"308 rel="noopener noreferrer"309 className="inline-flex items-center gap-1.5 rounded-[0.5rem] border border-foreground/15 bg-card px-3 py-1.5 text-xs font-medium text-foreground/80 shadow-[var(--shadow-soft)] transition-all hover:-translate-y-px hover:border-primary/50 hover:text-primary"310 >311 <FileText className="h-3.5 w-3.5" aria-hidden="true" />312 PDF313 </a>314 {paper.ssrn && (315 <a316 href={paper.ssrn}317 target="_blank"318 rel="noopener noreferrer"319 className="inline-flex items-center gap-1.5 rounded-[0.5rem] border border-foreground/15 bg-card px-3 py-1.5 text-xs font-medium text-foreground/80 shadow-[var(--shadow-soft)] transition-all hover:-translate-y-px hover:border-primary/50 hover:text-primary"320 >321 <ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />322 SSRN323 </a>324 )}325 {paper.repo && (326 <a327 href={paper.repo}328 target="_blank"329 rel="noopener noreferrer"330 className="inline-flex items-center gap-1.5 rounded-[0.5rem] border border-foreground/15 bg-card px-3 py-1.5 text-xs font-medium text-foreground/80 shadow-[var(--shadow-soft)] transition-all hover:-translate-y-px hover:border-primary/50 hover:text-primary"331 >332 <Github className="h-3.5 w-3.5" aria-hidden="true" />333 Code334 </a>335 )}336 </div>337 </div>338 </div>339 </li>340 );341}342