SPB Git

Repository Public

research-explorer.tsx 13.5 KB 342 lines tsx

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
Files main
Menu

Theme