SPB Git

spb/autoscholar

Public
11.4 KB · 342 lines tsx
1"use client";2import { DISCIPLINE_LABELS, RESEARCH_MODE_LABELS } from "@autoscholar/schemas";3import Link from "next/link";4import { useEffect, useRef, useState } from "react";5import { IconClose, IconFilter, IconSearch } from "./Icons";67type Q = Record<string, string | undefined>;8const FILTER_KEYS = ["discipline", "mode", "series", "status", "from", "to", "code", "data"] as const;910function hrefWithout(q: Q, key: string) {11  const u = new URLSearchParams();12  for (const [k, v] of Object.entries(q)) if (v && k !== key && k !== "page") u.set(k, v);13  const s = u.toString();14  return s ? `/research?${s}` : "/research";15}1617function Fields({18  q,19  series,20  idPrefix,21}: {22  q: Q;23  series: Array<{ slug: string; name: string }>;24  idPrefix: string;25}) {26  const id = (s: string) => `${idPrefix}-${s}`;27  return (28    <div className="grid gap-4">29      <div>30        <label htmlFor={id("discipline")} className="label mb-1">31          Discipline32        </label>33        <select id={id("discipline")} name="discipline" defaultValue={q.discipline ?? ""} className="input">34          <option value="">All disciplines</option>35          {Object.entries(DISCIPLINE_LABELS).map(([k, v]) => (36            <option key={k} value={k}>37              {v}38            </option>39          ))}40        </select>41      </div>42      <div>43        <label htmlFor={id("mode")} className="label mb-1">44          Methodology45        </label>46        <select id={id("mode")} name="mode" defaultValue={q.mode ?? ""} className="input">47          <option value="">All methodologies</option>48          {Object.entries(RESEARCH_MODE_LABELS).map(([k, v]) => (49            <option key={k} value={k}>50              {v}51            </option>52          ))}53        </select>54      </div>55      <div className="grid grid-cols-2 gap-3">56        <div>57          <label htmlFor={id("series")} className="label mb-1">58            Series59          </label>60          <select id={id("series")} name="series" defaultValue={q.series ?? ""} className="input">61            <option value="">All</option>62            {series.map((s) => (63              <option key={s.slug} value={s.slug}>64                {s.name}65              </option>66            ))}67          </select>68        </div>69        <div>70          <label htmlFor={id("status")} className="label mb-1">71            Status72          </label>73          <select id={id("status")} name="status" defaultValue={q.status ?? ""} className="input">74            <option value="">Any</option>75            <option value="published">Published</option>76            <option value="corrected">Corrected</option>77            <option value="retracted">Retracted</option>78          </select>79        </div>80      </div>81      <div className="grid grid-cols-2 gap-3">82        <div>83          <label htmlFor={id("from")} className="label mb-1">84            From85          </label>86          <input id={id("from")} name="from" type="date" defaultValue={q.from ?? ""} className="input" />87        </div>88        <div>89          <label htmlFor={id("to")} className="label mb-1">90            To91          </label>92          <input id={id("to")} name="to" type="date" defaultValue={q.to ?? ""} className="input" />93        </div>94      </div>95      <fieldset>96        <legend className="label mb-2">Availability</legend>97        <div className="flex gap-5">98          <label className="flex min-h-10 items-center gap-2 text-sm">99            <input100              type="checkbox"101              name="code"102              value="1"103              defaultChecked={q.code === "1"}104              className="h-4 w-4"105            />{" "}106            Code107          </label>108          <label className="flex min-h-10 items-center gap-2 text-sm">109            <input110              type="checkbox"111              name="data"112              value="1"113              defaultChecked={q.data === "1"}114              className="h-4 w-4"115            />{" "}116            Data117          </label>118        </div>119      </fieldset>120    </div>121  );122}123124/** Active-filter chips (applied filters); each chip removes its own filter. */125export function ActiveFilters({ q, series }: { q: Q; series: Array<{ slug: string; name: string }> }) {126  const labels: Array<[string, string]> = [];127  if (q.discipline)128    labels.push(["discipline", (DISCIPLINE_LABELS as Record<string, string>)[q.discipline] ?? q.discipline]);129  if (q.mode) labels.push(["mode", (RESEARCH_MODE_LABELS as Record<string, string>)[q.mode] ?? q.mode]);130  if (q.series) labels.push(["series", series.find((s) => s.slug === q.series)?.name ?? q.series]);131  if (q.status) labels.push(["status", q.status]);132  if (q.from) labels.push(["from", `from ${q.from}`]);133  if (q.to) labels.push(["to", `to ${q.to}`]);134  if (q.code === "1") labels.push(["code", "has code"]);135  if (q.data === "1") labels.push(["data", "has data"]);136  if (labels.length === 0) return null;137  return (138    <ul className="flex flex-wrap items-center gap-2" aria-label="Applied filters">139      {labels.map(([k, l]) => (140        <li key={k}>141          <Link142            href={hrefWithout(q, k)}143            className="chip chip-active no-underline"144            aria-label={`Remove filter ${l}`}145          >146            {l} <IconClose size={12} />147          </Link>148        </li>149      ))}150      <li>151        <Link152          href={q.q ? `/research?q=${encodeURIComponent(q.q)}` : "/research"}153          className="chip no-underline"154        >155          Clear all156        </Link>157      </li>158    </ul>159  );160}161162/**163 * LibraryFilters — search bar + sort on every width; facet sidebar on desktop, bottom-sheet on mobile with an164 * active-filter count. Plain GET form so it works without JavaScript.165 */166export function LibraryFilters({167  q,168  series,169  total,170}: {171  q: Q;172  series: Array<{ slug: string; name: string }>;173  total: number;174}) {175  const [open, setOpen] = useState(false);176  const active = FILTER_KEYS.filter((k) => q[k]).length;177  const sheet = useRef<HTMLDivElement>(null);178  useEffect(() => {179    if (!open) return;180    const prev = document.body.style.overflow;181    document.body.style.overflow = "hidden";182    sheet.current?.querySelector<HTMLElement>("select,input,button")?.focus();183    const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);184    document.addEventListener("keydown", onKey);185    return () => {186      document.body.style.overflow = prev;187      document.removeEventListener("keydown", onKey);188    };189  }, [open]);190  const hidden = (prefix: string) => (191    <>192      {q.q && <input type="hidden" name="q" value={q.q} />}193      {q.sort && <input type="hidden" name="sort" value={q.sort} />}194      <span className="sr-only">{prefix}</span>195    </>196  );197  return (198    <search>199      {/* search + sort row */}200      <form201        method="get"202        action="/research"203        className="flex flex-col gap-2 sm:flex-row sm:items-center"204        aria-label="Search the library"205      >206        {FILTER_KEYS.map((k) => q[k] && <input key={k} type="hidden" name={k} value={q[k]} />)}207        <div className="relative flex-1">208          <label htmlFor="q" className="sr-only">209            Search210          </label>211          <IconSearch212            size={18}213            className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 subtle"214          />215          <input216            id="q"217            name="q"218            type="search"219            defaultValue={q.q ?? ""}220            placeholder="Search titles, abstracts, keywords or paper IDs"221            className="input pl-10"222            enterKeyHint="search"223          />224        </div>225        <div className="flex gap-2">226          <label htmlFor="sort" className="sr-only">227            Sort228          </label>229          <select230            id="sort"231            name="sort"232            defaultValue={q.sort ?? "date"}233            className="input w-auto min-w-[8.5rem]"234          >235            <option value="date">Newest first</option>236            <option value="relevance">Most relevant</option>237            <option value="title">Title A–Z</option>238          </select>239          <button type="submit" className="btn btn-primary">240            Search241          </button>242          <button243            type="button"244            className="btn lg:hidden"245            aria-expanded={open}246            aria-controls="filter-sheet"247            onClick={() => setOpen(true)}248          >249            <IconFilter size={16} /> Filters250            {active > 0 && (251              <span252                className="ml-1 grid h-5 min-w-5 place-items-center rounded-full px-1 text-[11px]"253                style={{ background: "var(--accent)", color: "var(--accent-fg)" }}254              >255                {active}256              </span>257            )}258          </button>259        </div>260      </form>261      <div className="mt-3 flex flex-wrap items-center justify-between gap-3">262        <ActiveFilters q={q} series={series} />263        <p className="muted text-sm" aria-live="polite">264          {total === 0 ? "No results" : `${total} ${total === 1 ? "paper" : "papers"}`}265          {q.q ? ` for “${q.q}”` : ""}266        </p>267      </div>268269      {/* mobile bottom sheet */}270      {open && (271        <>272          <button273            type="button"274            className="drawer-backdrop lg:hidden"275            aria-label="Close filters"276            onClick={() => setOpen(false)}277          />278          <div279            ref={sheet}280            id="filter-sheet"281            role="dialog"282            aria-modal="true"283            aria-label="Filters"284            className="sheet-panel lg:hidden"285          >286            <div className="sheet-handle" aria-hidden="true" />287            <div className="flex items-center justify-between">288              <p className="title-3">Filters</p>289              <button290                type="button"291                className="btn-icon"292                aria-label="Close filters"293                onClick={() => setOpen(false)}294              >295                <IconClose />296              </button>297            </div>298            <form method="get" action="/research" className="mt-4" aria-label="Filter the library (mobile)">299              {hidden("Filters")}300              <Fields q={q} series={series} idPrefix="sheet" />301              <div className="sticky bottom-0 mt-5 flex gap-2 bg-[var(--bg)] pt-3">302                <button type="submit" className="btn btn-primary flex-1">303                  Show results304                </button>305                <Link href={q.q ? `/research?q=${encodeURIComponent(q.q)}` : "/research"} className="btn">306                  Reset307                </Link>308              </div>309            </form>310          </div>311        </>312      )}313    </search>314  );315}316317/** Desktop facet sidebar (plain GET form). Rendered in the library's left column. */318export function FilterSidebar({ q, series }: { q: Q; series: Array<{ slug: string; name: string }> }) {319  return (320    <form321      method="get"322      action="/research"323      className="surface-flat sticky top-[calc(var(--header-h)+1rem)] p-5"324      aria-label="Filter the library"325      id="filter-sidebar"326    >327      {q.q && <input type="hidden" name="q" value={q.q} />}328      {q.sort && <input type="hidden" name="sort" value={q.sort} />}329      <p className="label mb-4">Refine</p>330      <Fields q={q} series={series} idPrefix="side" />331      <div className="mt-5 flex gap-2">332        <button type="submit" className="btn btn-primary flex-1">333          Apply334        </button>335        <Link href={q.q ? `/research?q=${encodeURIComponent(q.q)}` : "/research"} className="btn">336          Reset337        </Link>338      </div>339    </form>340  );341}342
Files · main
Menu

Theme