spb/autoscholar
Public
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