1353 lines · 53.3 KB · TypeScript
RawDownload
"use client"; // The entry list, as wide cards.//// Card height is deliberately variable and nothing reserves space for prose:// only about one entry in five has a `statement` or an `aiRole`, so a fixed// card would render four-fifths of the list as mostly-empty rectangles. A lean// Erdős entry collapses to two compact lines; a marquee entry grows to carry its// statement. The fields that ARE always present (type, who posed it, year, age,// model, verification, notability) carry the visual weight instead.//// Cards have no column headers, so sorting moved into an explicit control. import { useEffect, useMemo, useRef, useState } from "react";import { useBeforePaint } from "@/lib/before-paint";import { inferLinkKind, topLinkKinds } from "@/lib/link-kinds";import {  DEFAULT_SETTINGS,  NUMERIC_KEYS,  PAGE_SIZES,  PERIODS,  PERIOD_DAYS,  SETTINGS_COOKIE,  SETTINGS_COOKIE_MAX_AGE,  SETTINGS_KEY,  SORTS,  TIME_SENSITIVE,  normalizeListSettings,  parseSelection,  selectionMatches,  sortValue,  toggleSelection,  type ListSettings,  type SortDir,  type SortKey,} from "@/lib/list-settings";import { SOURCE_HOSTS, sourceHostKey } from "@/lib/source-hosts";import Link from "next/link";import {  ageAtSolve,  AI_CONTRIBUTIONS,  FIELD_GROUPS,  PUBLICATION_STATUSES,  RESOLUTION_METHODS,  RESOLUTION_STATUSES,  type CardEntry,  type FieldGroup,  type Period,  type SolveType,  type VerificationStatus,} from "@/lib/problems";import { entrySourceIds, extractSourceId } from "@/lib/source-ids";import {  AI_CONTRIBUTION,  DASH,  MODEL_FAMILIES,  PUBLICATION,  RESOLUTION,  RESOLUTION_METHOD,  SIGNIFICANCE_HELP,  SOLVE_TYPE,  VERIFICATION,} from "@/lib/display";import { FilterPanel, type FilterFacet } from "@/components/FilterPanel";import { Icon, type IconName } from "@/components/Icons";import { StatusIcon } from "@/components/StatusIcon";import { InfoTip, StarNote } from "@/components/Tooltip";import { VoteButtons } from "@/components/VoteButtons";import { TeX, deTeX } from "@/components/TeX"; // The clock for period cutoffs, fixed at module load: render purity wants a// stable now, and a cutoff drifting by the age of the tab is nothing against// 7/30-day windows.const LOADED_AT = Date.now(); /// Compact page list: first, last, current and neighbours, with gaps elsewhere.function pageWindow(current: number, total: number): (number | "gap")[] {  if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);  const wanted = new Set([1, total, current, current - 1, current + 1]);  const nums = [...wanted]    .filter((p) => p >= 1 && p <= total)    .sort((a, b) => a - b);  const out: (number | "gap")[] = [];  let prev = 0;  for (const p of nums) {    if (p - prev > 1) out.push("gap");    out.push(p);    prev = p;  }  return out;} function Chevron({ dir }: { dir: "left" | "right" }) {  return (    <svg      width="14"      height="14"      viewBox="0 0 24 24"      fill="none"      stroke="currentColor"      strokeWidth={2.5}      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden    >      <path d={dir === "left" ? "M15 18l-6-6 6-6" : "M9 6l6 6-6 6"} />    </svg>  );} /// One dot-separated fact in the card's fact line. Deliberately NOT/// whitespace-nowrap: values like a six-model credit line are wider than a/// phone-sized card, so a fact must be able to wrap internally rather than/// overflow the card.function Fact({  label,  children,}: {  label: string;  children: React.ReactNode;}) {  return (    <span>      <span className="text-[var(--ink-muted)]">{label} </span>      <span className="text-[var(--ink-secondary)]">{children}</span>    </span>  );} /// "8 Aug 2026" from an ISO timestamp. Deliberately not RelativeTime: this/// sits in a dense metadata row where "3 days ago" would need re-reading/// against the solve date next to it, and unlike the feed these dates are/// mostly weeks old, where the absolute form is the more useful one.function formatAddedDate(iso: string): string {  const d = new Date(iso);  return `${d.getUTCDate()} ${MONTHS_SHORT[d.getUTCMonth()]} ${d.getUTCFullYear()}`;} const MONTHS_SHORT = [  "Jan",  "Feb",  "Mar",  "Apr",  "May",  "Jun",  "Jul",  "Aug",  "Sep",  "Oct",  "Nov",  "Dec",]; function ProblemCard({  p,  statementHtml,}: {  p: CardEntry;  statementHtml: string | null;}) {  const st = SOLVE_TYPE[p.solveType];  // The primary source counts as a link. For 265 of the entries it IS the  // paper, so leaving it out would have shown a paper icon only on the  // minority that happen to carry a second copy as an extra link. It has no  // stored kind of its own, so it is classified from its URL and name.  const linkIcons = topLinkKinds([    {      url: p.sourceUrl,      label: p.sourceName,      kind: inferLinkKind(p.sourceUrl, p.sourceName),    },    ...p.links,  ]);  // The trust badge: verification when it says something; the publication  // stage as the fallback when nobody independent has checked yet -  // reproducing the old single-ladder look over honest two-axis data.  const v =    p.verification === "unreviewed" && p.publication      ? PUBLICATION[p.publication]      : VERIFICATION[p.verification];  const res = RESOLUTION[p.resolution];  const age = ageAtSolve(p);   // Result dot + status pill + contribution pill. Rendered twice: beside the  // title on desktop, but on a phone the header column stops ~90px short of  // the card edge (the vote column reserves its width even below the  // buttons), which wrapped the pills after two despite visible space - so  // there they render below the header at the true full card width.  const badges = (    <>      <span className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs">        <span          aria-hidden          className="inline-block h-2 w-2 rounded-full"          style={{ backgroundColor: st.color }}        />        <span className="text-[var(--ink-secondary)]">{st.label}</span>      </span>      {/* A non-default resolution qualifies the headline claim, so it sits          right beside it as a small pill. "Resolved" renders nothing - the          default state should add no noise. */}      {res.pill && (        <span          className="inline-flex items-center whitespace-nowrap rounded-full border px-2 py-px text-[11px] font-medium"          style={{            color: res.color,            borderColor: `color-mix(in srgb, ${res.color} 40%, transparent)`,          }}        >          {res.pill}        </span>      )}      {/* Same convention for the AI-contribution axis; unclassified entries          show nothing. */}      {p.aiContribution && AI_CONTRIBUTION[p.aiContribution]?.pill && (        <span          className="inline-flex items-center whitespace-nowrap rounded-full border px-2 py-px text-[11px] font-medium"          style={{            color: AI_CONTRIBUTION[p.aiContribution].color,            borderColor: `color-mix(in srgb, ${AI_CONTRIBUTION[p.aiContribution].color} 40%, transparent)`,          }}        >          {AI_CONTRIBUTION[p.aiContribution].pill}        </span>      )}    </>  );   return (    // The whole card is clickable: the title link's ::after stretches over the    // card (so semantics and keyboard focus stay on a real link), and every    // interactive child sits above the overlay with `relative z-10`.    // Hover/focus feedback lives in globals.css (.entry-card), gated to real    // mouse pointers and keyboard focus: on touch devices an emulated :hover    // or tap-focus repaint is exactly the "card flashes when I press it"    // report under Chrome's auto-dark mode, and a finger on the card needs no    // hover affordance anyway.    <article className="entry-card group relative rounded-md border border-[var(--hairline)] bg-[var(--paper-raised)] px-4 py-3.5 transition-colors">      {/* Only the HEADER shares a row with the votes. Everything below runs          the full width of the card - when the votes sat in a full-height          flex column they reserved their width all the way down, wrapping          statements at ~60% width on a phone for no reason. */}      <div className="flex items-start gap-4">        <div className="min-w-0 flex-1">          {/* Title + result */}          <div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">            <h3 className="font-serif text-base leading-snug text-[var(--ink)] sm:text-lg">              <Link                href={`/problem/${p.slug}`}                className="after:absolute after:inset-0 after:content-['']"              >                <TeX>{p.name}</TeX>              </Link>            </h3>            {/* Desktop placement: beside the title, as one group. */}            <span className="hidden flex-wrap items-center gap-x-2.5 gap-y-1 sm:flex">              {badges}            </span>          </div>        </div>         {/* Votes, kept out of the flowing text so they line up down the list */}        <div className="relative z-10 shrink-0 pt-0.5">          <VoteButtons            slug={p.slug}            upvotes={p.upvotes}            downvotes={p.downvotes}          />        </div>      </div>       {/* Everything below the header spans the whole card. */}      <div className="min-w-0">        {/* Mobile placement of the badges: full card width, so all three fit            one line on any normal phone. */}        <span className="mt-1 flex flex-wrap items-center gap-x-2.5 gap-y-1 sm:hidden">          {badges}        </span>        {/* The result qualifier can be a whole sentence, so it lives below            the header row where it wraps at the FULL card width instead of            squeezing beside the vote buttons. */}        {p.resultNote && (          // Clamped: at the 200-character cap this is five lines on a phone,          // which on a card buries the identity line under a caveat. The entry          // page carries it in full.          <p className="mt-0.5 line-clamp-2 text-xs text-[var(--ink-muted)]">            ({p.resultNote})          </p>        )}        {/* Identity line */}        <p className="mt-1 font-mono text-[11px] text-[var(--ink-muted)]">          {p.problemNumber !== null && <>Erdős #{p.problemNumber} · </>}          {p.field ?? DASH}        </p>         {/* Statement, rendered to HTML on the server so no KaTeX runs here.            Beyond the first page it arrives via the lazy statements map. */}        {statementHtml && (          <p            className="math-prose mt-2.5 text-sm leading-relaxed text-[var(--ink-secondary)]"            dangerouslySetInnerHTML={{ __html: statementHtml }}          />        )}         {/* Facts that every entry has */}        {/* items-baseline: the ageNote's StarNote is an align-super button,            which grows its fact's line box upward - without a shared baseline            the flex default let that whole fact sit visibly lower than its            neighbours ("Open 11y*" hung below "Posed by" and "Model"). */}        <div className="mt-2.5 flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono text-[11px]">          <Fact label="Posed by">            {p.posedBy ?? DASH}            {p.yearPosed !== null && `, ${p.yearPosed}`}          </Fact>          <span aria-hidden className="text-[var(--hairline)]">            ·          </span>          <Fact label="Open">            {age !== null ? `${age}y` : DASH}            {p.ageNote && <StarNote text={p.ageNote} />}          </Fact>          <span aria-hidden className="text-[var(--hairline)]">            ·          </span>          <Fact label="Model">            {p.model}            {p.modelMaker && ` (${p.modelMaker})`}          </Fact>          <span aria-hidden className="text-[var(--hairline)]">            ·          </span>          <Fact label="Solved">{p.solveDate}</Fact>        </div>         {/* Verification + notability + discussion */}        <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px]">          {/* Reserved width so Significance starts at the same x down the                list instead of stepping in and out with the badge text.                Measured rather than guessed: the widest badge in common use is                "Site-confirmed" at 93px, so 6rem reserves 3px of slack. The                first attempt used 7.75rem, which aligned correctly but left                ~30px of dead gap that read as broken padding.                 Applied at every width. An earlier version scoped this to `sm`                on the theory that the row wraps on a phone; it does not -                measured at 360px, nothing wraps to a second line, so the only                thing the breakpoint achieved was leaving every screen under                640px ragged. */}          <span            className="relative z-10 inline-flex items-center gap-1.5 min-w-[6rem]"            style={{ color: v.color }}            title={p.verificationNote ?? undefined}          >            <StatusIcon kind={v.icon} color={v.color} />            {v.label}          </span>           {/* A documented problem with the claim itself - rare, loud. */}          {p.claimIssueNote && (            <span              className="relative z-10 inline-flex items-center gap-1.5 text-[var(--status-critical)]"              title={p.claimIssueNote}            >              <StatusIcon kind="alert" color="var(--status-critical)" />              Claim issue            </span>          )}           {/* AI-estimated problem weight; the Wikipedia count moved to the                entry page as a supporting fact (it was almost always 0 here). */}          {p.significance !== null && p.significance !== undefined && (            // inline-flex, not a plain span. Its neighbours in this row -            // the verification badge and the comment link - are inline-flex            // boxes because they carry icons, and a plain inline span sits            // 1.83px lower than they do under `items-center`, which centres            // boxes rather than text. Measured: matching the box type takes            // the skew to exactly 0. It is not a font-metric problem;            // forcing both spans to the same font family changes nothing.            <span className="relative z-10 inline-flex items-center font-mono text-[var(--ink-muted)]">              {/* The metric explanation belongs to the label+value only; the                    star carries the per-entry justification. Nesting the star                    under the same title showed BOTH bubbles when hovering it. */}              <span title={SIGNIFICANCE_HELP}>                Significance{" "}                <span className="text-[var(--ink-secondary)]">                  {p.significance}                </span>              </span>              {p.significanceNote && <StarNote text={p.significanceNote} />}            </span>          )}           {/* Straight to the artifact. The question a reader most often has                about an entry on this page is "is there a paper" or "is there                a Lean proof", and answering it used to mean opening the entry                and reading a list of free-text labels. These are the entry's                own links, typed, so the icon is a promise about what is on the                other end. z-10 lifts them above the card's click overlay. */}          {linkIcons.length > 0 && (            <span className="relative z-10 inline-flex items-center gap-1.5">              {linkIcons.map(({ spec, link }) => (                <a                  key={spec.value}                  href={link.url}                  target="_blank"                  rel="noopener noreferrer"                  title={`${spec.label}: ${spec.help}`}                  aria-label={spec.label}                  className="text-[var(--ink-muted)] transition-colors hover:text-[var(--accent-blue)]"                >                  <Icon name={spec.icon as IconName} size={14} />                </a>              ))}            </span>          )}           {/* Credit where an entry came from a reader - contributors should                see their name on the front page, not only on the entry page.                Links to their profile; z-10 lifts it above the card overlay. */}          {p.submittedBy && (            // The space is a non-breaking one inside the text, not a `{" "}`            // between the label and the link. Flexbox drops a whitespace-only            // text node sitting between two flex items, so the words ran            // together the moment this span became inline-flex; a trailing            // ordinary space would be trimmed at the end of its own flex item            // too. `gap-1` would work but at 4px reads tighter than the            // natural space in `Fact` label/value pairs on the row above.            // The Significance span is unaffected only because its space            // lives inside a nested inline span.            <span className="relative z-10 inline-flex items-center font-mono text-[var(--ink-muted)]">              Submitted by&nbsp;              <Link                href={`/user/${encodeURIComponent(p.submittedBy)}`}                className="text-[var(--ink-secondary)] hover:text-[var(--accent-blue)] hover:underline"              >                {p.submittedBy}              </Link>              {/* Only alongside a submitter, never on its own. `addedAt` is                    row creation, and the curated baseline was seeded within                    the same few seconds, so on those entries it records when                    the database was filled rather than anything about the                    entry. Beside a pseudonym it means what it looks like. */}              &nbsp;on&nbsp;{formatAddedDate(p.addedAt)}            </span>          )}           {p.commentCount > 0 && (            <Link              href={`/problem/${p.slug}#discussion`}              className="relative z-10 inline-flex items-center gap-1 font-mono text-[var(--accent-blue)] hover:underline"            >              <Icon name="bubble" size={12} />              {p.commentCount}              <span className="sr-only">                {p.commentCount === 1 ? "comment" : "comments"}              </span>            </Link>          )}        </div>      </div>    </article>  );} export function ProblemCards({  problems,  initial = DEFAULT_SETTINGS,}: {  problems: CardEntry[];  /// The view the SERVER already rendered, read from the settings cookie.  /// Seeding state from it is the whole point of the cookie: the markup and  /// the first client render agree, so there is no reshuffle to watch.  initial?: ListSettings;}) {  const [query, setQuery] = useState("");  const [fieldFilter, setFieldFilter] = useState(initial.fieldFilter);  const [resultFilter, setResultFilter] = useState(initial.resultFilter);  const [statusFilter, setStatusFilter] = useState(initial.statusFilter);  const [contributionFilter, setContributionFilter] = useState(    initial.contributionFilter,  );  const [modelFilter, setModelFilter] = useState(initial.modelFilter);  const [verificationFilter, setVerificationFilter] = useState(    initial.verificationFilter,  );  const [publicationFilter, setPublicationFilter] = useState(    initial.publicationFilter,  );  const [methodFilter, setMethodFilter] = useState(initial.methodFilter);  const [sourceFilter, setSourceFilter] = useState(initial.sourceFilter);  const [sortKey, setSortKey] = useState<SortKey>(initial.sortKey);  const [sortDir, setSortDir] = useState<SortDir>(initial.sortDir);  const [period, setPeriod] = useState<Period>(initial.period);  const [page, setPage] = useState(1);  const [perPage, setPerPage] = useState(initial.perPage);   // Field chips: every group that actually occurs, with its count, BIGGEST  // FIRST. Fixed taxonomy order put one-entry groups ahead of hundred-entry  // ones, so the chips a reader is most likely to want were the ones they had  // to hunt for. Ties fall back to taxonomy order to keep the row stable

Lines 1–500 of 1353.