"use client"; import { QuoteIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Fragment, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface FootnotedSentence { text: string; cites?: number[]; breakAfter?: boolean; } export interface FootnoteSource { title: string; domain: string; passage: string; } export interface FootnotedAnswerProps { sentences?: FootnotedSentence[]; sources?: FootnoteSource[]; className?: string; } const defaultSources: FootnoteSource[] = [ { title: "Field notes on urban beekeeping", domain: "hivecraft.org", passage: "Rooftop colonies in dense cities often forage across three kilometres or more, and the variety of street trees and balcony plants gives urban honey a surprisingly complex flavour.", }, { title: "Pollinator decline in managed landscapes", domain: "meadowlab.edu", passage: "Adding managed hives can increase competition with wild bees for limited flowers, especially where green space is scarce and native pollinators are already under pressure.", }, { title: "Planting for pollinators: a city guide", domain: "greenblock.city", passage: "Councils seeing the strongest results pair new hives with native flowers along verges and parks, so that forage grows faster than the number of colonies.", }, ]; const defaultSentences: FootnotedSentence[] = [ { text: "City bees tend to do well: rooftop colonies forage over kilometres and urban honey has a complex flavour.", cites: [1] }, { text: "The catch is competition, since extra hives can crowd out wild bees where flowers are scarce.", cites: [2], breakAfter: true, }, { text: "The best programs plant native flowers first and add colonies slowly.", cites: [3, 2] }, ]; const spring = { type: "spring", stiffness: 420, damping: 34 } as const; const easeOut = [0.23, 1, 0.32, 1] as const; const tone = [ { dot: "bg-orange-500", hl: "from-orange-500/20 to-orange-500/20", ring: "ring-orange-500/60", mark: "from-orange-500/25 to-orange-500/25" }, { dot: "bg-sky-500", hl: "from-sky-500/20 to-sky-500/20", ring: "ring-sky-500/60", mark: "from-sky-500/25 to-sky-500/25" }, { dot: "bg-emerald-500", hl: "from-emerald-500/20 to-emerald-500/20", ring: "ring-emerald-500/60", mark: "from-emerald-500/25 to-emerald-500/25" }, { dot: "bg-violet-500", hl: "from-violet-500/20 to-violet-500/20", ring: "ring-violet-500/60", mark: "from-violet-500/25 to-violet-500/25" }, { dot: "bg-rose-500", hl: "from-rose-500/20 to-rose-500/20", ring: "ring-rose-500/60", mark: "from-rose-500/25 to-rose-500/25" }, ] as const; const toneOf = (n: number) => tone[(n - 1) % tone.length] ?? tone[0]; const stop = new Set( "that this with from have they their there where which while about into over than then them these those when what will would could should been being also very just more most some such only other many much each your ours".split( " ", ), ); function stem(word: string) { return word .toLowerCase() .replace(/[^a-z0-9]/g, "") .replace(/(ing|ed|es|s)$/, ""); } function keywords(texts: string[]) { const set = new Set(); for (const t of texts) for (const w of t.split(/\s+/)) { const clean = w.toLowerCase().replace(/[^a-z0-9]/g, ""); if (clean.length >= 4 && !stop.has(clean)) set.add(stem(clean)); } return set; } type Focus = { source: number; sentence?: number } | null; export function FootnotedAnswer({ sentences = defaultSentences, sources = defaultSources, className }: FootnotedAnswerProps) { const reduce = useReducedMotion(); const [hover, setHover] = useState(null); const [open, setOpen] = useState(null); const focus = hover ?? open; const lastSource = useRef(1); if (focus) lastSource.current = focus.source; const lit = (i: number) => { if (!focus) return false; const s = sentences[i]; if (focus.sentence !== undefined) return focus.sentence === i; return Boolean(s?.cites?.includes(focus.source)); }; const toggle = (next: { source: number; sentence?: number }) => setOpen((cur) => (cur && cur.source === next.source && cur.sentence === next.sentence ? null : next)); const paragraphs = useMemo(() => { const out: number[][] = [[]]; sentences.forEach((s, i) => { out[out.length - 1]?.push(i); if (s.breakAfter && i < sentences.length - 1) out.push([]); }); return out; }, [sentences]); const openSource = open ? sources[open.source - 1] : undefined; const markWords = useMemo(() => { if (!open) return new Set(); const texts = open.sentence !== undefined ? [sentences[open.sentence]?.text ?? ""] : sentences.filter((s) => s.cites?.includes(open.source)).map((s) => s.text); return keywords(texts); }, [open, sentences]); return (
setHover(null)} >
{paragraphs.map((ids) => (

{ids.map((i) => { const s = sentences[i]; if (!s) return null; const on = lit(i); const t = toneOf(lastSource.current); const hl = cn( "rounded-sm bg-linear-to-r bg-no-repeat transition-[background-size,color] duration-300 ease-out motion-reduce:transition-none", t.hl, on ? "bg-size-[100%_100%] text-foreground" : "bg-size-[0%_100%]", ); const cut = s.text.lastIndexOf(" "); const head = cut > 0 ? s.text.slice(0, cut + 1) : ""; const tail = cut > 0 ? s.text.slice(cut + 1) : s.text; return ( {head && {head}} {tail} {s.cites?.map((n) => { const active = focus?.source === n && (focus.sentence === undefined || focus.sentence === i); const pressed = open?.source === n && open.sentence === i; return ( ); })} {" "} ); })}

))}
{open && openSource && (
{openSource.domain}

)}
{sources.map((src, idx) => { const n = idx + 1; const active = focus?.source === n; return ( setHover(null)} onClick={() => toggle({ source: n })} onFocus={() => setHover({ source: n })} onPointerEnter={(e) => e.pointerType === "mouse" && setHover({ source: n })} onPointerLeave={() => setHover(null)} transition={spring} type="button" > {src.domain} {n} {src.title} ); })}
); } function Passage({ text, words, mark }: { text: string; words: Set; mark: string }) { const reduce = useReducedMotion(); let hit = 0; return ( <> {text.split(/(\s+)/).map((part, i) => { if (/^\s+$/.test(part) || !words.has(stem(part))) return {part}; const delay = 0.1 + Math.min(hit++, 6) * 0.04; const [, word = part, trail = ""] = /^(.*?)([.,;:!?]*)$/.exec(part) ?? []; return ( {word} {trail} ); })} ); }