| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useSearchParams } from "next/navigation"; |
| 5 | import Link from "next/link"; |
| 6 | import { Card, SectionHeader } from "@/components/ui"; |
| 7 | import { SourceLink } from "@/components/source-link"; |
| 8 | import type { |
| 9 | CalendarEvent, |
| 10 | CalendarKind, |
| 11 | CalendarDayKind, |
| 12 | } from "@/lib/feed"; |
| 13 | import { clsx } from "@/lib/cx"; |
| 14 | |
| 15 | |
| 16 | type KindMeta = { value: CalendarKind; plain: string; mark: string; color: string }; |
| 17 | |
| 18 | const KINDS: KindMeta[] = [ |
| 19 | { value: "effective", plain: "Takes effect", mark: "Effective", color: "var(--dk-hold)" }, |
| 20 | { value: "effective_parsed", plain: "Takes effect", mark: "Effective", color: "var(--dk-hold)" }, |
| 21 | { value: "enacted", plain: "Became law", mark: "Law", color: "var(--dk-yea)" }, |
| 22 | { value: "vote", plain: "Vote", mark: "Vote", color: "var(--dk-party-d)" }, |
| 23 | { value: "floor", plain: "Floor action", mark: "Floor", color: "var(--dk-party-r)" }, |
| 24 | { value: "committee", plain: "Committee", mark: "Committee", color: "var(--dk-party-i)" }, |
| 25 | { value: "introduced", plain: "Introduced", mark: "Introduced", color: "var(--dk-ash)" }, |
| 26 | { value: "apportionment", plain: "Apportionment", mark: "Appr.", color: "var(--dk-hold)" }, |
| 27 | ]; |
| 28 | |
| 29 | const KIND_BY_VALUE = new Map<CalendarKind, KindMeta>(KINDS.map((k) => [k.value, k])); |
| 30 | |
| 31 | const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; |
| 32 | const MONTH_NAMES = [ |
| 33 | "January", "February", "March", "April", "May", "June", |
| 34 | "July", "August", "September", "October", "November", "December", |
| 35 | ]; |
| 36 | |
| 37 | function pad(n: number): string { |
| 38 | return n < 10 ? `0${n}` : String(n); |
| 39 | } |
| 40 | |
| 41 | function isoToday(): string { |
| 42 | const d = new Date(); |
| 43 | return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; |
| 44 | } |
| 45 | |
| 46 | function parseMonth(raw: string | undefined): { year: number; month: number } { |
| 47 | const m = /^(\d{4})-(\d{1,2})$/.exec(raw ?? ""); |
| 48 | if (m) { |
| 49 | const year = parseInt(m[1], 10); |
| 50 | const month = parseInt(m[2], 10); |
| 51 | if (year >= 1900 && year <= 9999 && month >= 1 && month <= 12) { |
| 52 | return { year, month }; |
| 53 | } |
| 54 | } |
| 55 | const d = new Date(); |
| 56 | return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }; |
| 57 | } |
| 58 | |
| 59 | function shiftMonth(year: number, month: number, delta: number): { year: number; month: number } { |
| 60 | const zero = month - 1 + delta; |
| 61 | const d = new Date(Date.UTC(year, zero, 1)); |
| 62 | return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1 }; |
| 63 | } |
| 64 | |
| 65 | export function CalendarBrowser() { |
| 66 | const sp = useSearchParams(); |
| 67 | const monthParam = sp.get("month") || undefined; |
| 68 | const dayParam = sp.get("day") || undefined; |
| 69 | const kindFilter = sp.get("kind") || undefined; |
| 70 | |
| 71 | const { year, month } = parseMonth(monthParam); |
| 72 | const todayIso = isoToday(); |
| 73 | |
| 74 | const selectedDay = |
| 75 | dayParam && /^\d{4}-\d{2}-\d{2}$/.test(dayParam) ? dayParam : undefined; |
| 76 | |
| 77 | const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); |
| 78 | const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay(); |
| 79 | const from = `${year}-${pad(month)}-01`; |
| 80 | const to = `${year}-${pad(month)}-${pad(daysInMonth)}`; |
| 81 | |
| 82 | const [daySummary, setDaySummary] = useState<CalendarDayKind[]>([]); |
| 83 | const [dayEvents, setDayEvents] = useState<CalendarEvent[]>([]); |
| 84 | const [loading, setLoading] = useState(true); |
| 85 | |
| 86 | useEffect(() => { |
| 87 | setLoading(true); |
| 88 | fetch(`/api/calendar?from=${from}&to=${to}&summary=1`) |
| 89 | .then((r) => r.json()) |
| 90 | .then((data) => setDaySummary(data.items ?? [])) |
| 91 | .catch(() => setDaySummary([])) |
| 92 | .finally(() => setLoading(false)); |
| 93 | }, [from, to]); |
| 94 | |
| 95 | useEffect(() => { |
| 96 | if (!selectedDay) { |
| 97 | setDayEvents([]); |
| 98 | return; |
| 99 | } |
| 100 | const params = new URLSearchParams(); |
| 101 | params.set("from", selectedDay); |
| 102 | params.set("to", selectedDay); |
| 103 | if (kindFilter) params.set("kind", kindFilter); |
| 104 | params.set("limit", "200"); |
| 105 | fetch(`/api/calendar?${params.toString()}`) |
| 106 | .then((r) => r.json()) |
| 107 | .then((data) => setDayEvents(data.items ?? [])) |
| 108 | .catch(() => setDayEvents([])); |
| 109 | }, [selectedDay, kindFilter]); |
| 110 | |
| 111 | const filteredSummary = kindFilter |
| 112 | ? daySummary.filter((s) => s.kind === kindFilter) |
| 113 | : daySummary; |
| 114 | const byDate = new Map<string, CalendarDayKind[]>(); |
| 115 | for (const s of filteredSummary) { |
| 116 | const arr = byDate.get(s.date) ?? []; |
| 117 | arr.push(s); |
| 118 | byDate.set(s.date, arr); |
| 119 | } |
| 120 | |
| 121 | const kindCounts = countKindsFromSummary(filteredSummary); |
| 122 | |
| 123 | const prev = shiftMonth(year, month, -1); |
| 124 | const next = shiftMonth(year, month, +1); |
| 125 | const prevHref = hrefFor(prev, selectedDay, kindFilter); |
| 126 | const nextHref = hrefFor(next, selectedDay, kindFilter); |
| 127 | const todayHref = hrefFor({ year: todayYear(), month: todayMonth() }, todayIso, kindFilter); |
| 128 | |
| 129 | const cells: Array<number | null> = []; |
| 130 | for (let i = 0; i < firstWeekday; i++) cells.push(null); |
| 131 | for (let d = 1; d <= daysInMonth; d++) cells.push(d); |
| 132 | while (cells.length % 7 !== 0) cells.push(null); |
| 133 | |
| 134 | return ( |
| 135 | <> |
| 136 | {} |
| 137 | <div className="mt-8 flex flex-wrap items-center justify-between gap-3"> |
| 138 | <div className="flex items-center gap-1"> |
| 139 | <Link |
| 140 | href={prevHref} |
| 141 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash hover:text-paper" |
| 142 | aria-label={`Previous month, ${MONTH_NAMES[prev.month - 1]} ${prev.year}`} |
| 143 | > |
| 144 | ← {MONTH_NAMES[prev.month - 1].slice(0, 3)} |
| 145 | </Link> |
| 146 | <p |
| 147 | className="display px-2 text-paper" |
| 148 | aria-live="polite" |
| 149 | style={{ fontSize: "clamp(1.25rem, 5vw, 1.875rem)" }} |
| 150 | > |
| 151 | {MONTH_NAMES[month - 1]} {year} |
| 152 | </p> |
| 153 | <Link |
| 154 | href={nextHref} |
| 155 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash hover:text-paper" |
| 156 | aria-label={`Next month, ${MONTH_NAMES[next.month - 1]} ${next.year}`} |
| 157 | > |
| 158 | {MONTH_NAMES[next.month - 1].slice(0, 3)} → |
| 159 | </Link> |
| 160 | </div> |
| 161 | <Link |
| 162 | href={todayHref} |
| 163 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border border-line px-3 text-ash underline underline-offset-4 hover:text-paper" |
| 164 | > |
| 165 | Today |
| 166 | </Link> |
| 167 | </div> |
| 168 | |
| 169 | {} |
| 170 | <div className="mt-5 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by kind"> |
| 171 | <Link |
| 172 | href={hrefFor({ year, month }, selectedDay, undefined)} |
| 173 | className={clsx( |
| 174 | "focus-ring label inline-flex min-h-[44px] items-center rounded-[3px] border px-2.5", |
| 175 | !kindFilter ? "border-paper text-paper" : "border-line text-ash hover:text-paper" |
| 176 | )} |
| 177 | aria-pressed={!kindFilter} |
| 178 | > |
| 179 | All |
| 180 | </Link> |
| 181 | {KINDS.filter((k, i) => KINDS.findIndex((x) => x.mark === k.mark) === i).map((k) => { |
| 182 | const active = kindFilter === k.value; |
| 183 | return ( |
| 184 | <Link |
| 185 | key={k.value} |
| 186 | href={hrefFor({ year, month }, selectedDay, active ? undefined : k.value)} |
| 187 | className={clsx( |
| 188 | "focus-ring label inline-flex min-h-[44px] items-center gap-1.5 rounded-[3px] border px-2.5", |
| 189 | active ? "border-paper text-paper" : "border-line text-ash hover:text-paper" |
| 190 | )} |
| 191 | aria-pressed={active} |
| 192 | > |
| 193 | <span |
| 194 | aria-hidden="true" |
| 195 | className="inline-block h-2 w-2 rounded-full" |
| 196 | style={{ backgroundColor: k.color }} |
| 197 | /> |
| 198 | {k.plain} |
| 199 | </Link> |
| 200 | ); |
| 201 | })} |
| 202 | </div> |
| 203 | |
| 204 | <div className="mt-6 grid gap-6 lg:grid-cols-[1.5fr_1fr]"> |
| 205 | {} |
| 206 | <div> |
| 207 | {} |
| 208 | <Card className="hidden overflow-hidden md:block"> |
| 209 | <table className="w-full table-fixed border-collapse text-left"> |
| 210 | <caption className="sr-only"> |
| 211 | Calendar grid for {MONTH_NAMES[month - 1]} {year}. Each cell is a |
| 212 | day; select a day to read its events. Rows are weeks, columns |
| 213 | are weekdays starting Sunday. |
| 214 | </caption> |
| 215 | <thead> |
| 216 | <tr className="border-b border-line"> |
| 217 | {WEEKDAYS.map((w) => ( |
| 218 | <th key={w} scope="col" className="label px-2 py-2.5 font-normal"> |
| 219 | {w} |
| 220 | </th> |
| 221 | ))} |
| 222 | </tr> |
| 223 | </thead> |
| 224 | <tbody> |
| 225 | {chunk(cells, 7).map((row, ri) => ( |
| 226 | <tr key={ri} className="border-b border-line last:border-0"> |
| 227 | {row.map((day, ci) => { |
| 228 | if (day == null) { |
| 229 | return ( |
| 230 | <td key={ci} aria-hidden="true" className="bg-ink/40 p-0"> |
| 231 | <div className="min-h-[5.5rem]" /> |
| 232 | </td> |
| 233 | ); |
| 234 | } |
| 235 | const dateStr = `${year}-${pad(month)}-${pad(day)}`; |
| 236 | const evs = byDate.get(dateStr) ?? []; |
| 237 | const evCount = dayEventCount(evs); |
| 238 | const isToday = dateStr === todayIso; |
| 239 | const isSelected = dateStr === selectedDay; |
| 240 | const marks = distinctMarks(evs); |
| 241 | const marksLabel = marks.map((m) => m.plain).join(", "); |
| 242 | return ( |
| 243 | <td key={ci} className="border-r border-line p-0 align-top last:border-r-0"> |
| 244 | <Link |
| 245 | href={hrefFor({ year, month }, dateStr, kindFilter)} |
| 246 | aria-label={`${day} ${MONTH_NAMES[month - 1]} ${year}${evCount ? `, ${evCount} event${evCount === 1 ? "" : "s"}${marksLabel ? `: ${marksLabel}` : ""}` : ", no events"}`} |
| 247 | aria-current={isSelected ? "true" : undefined} |
| 248 | className={clsx( |
| 249 | "focus-ring flex h-full min-h-[5.5rem] flex-col justify-between p-2 no-underline", |
| 250 | isSelected ? "bg-graphite" : "hover:bg-graphite/50" |
| 251 | )} |
| 252 | > |
| 253 | <span |
| 254 | className={clsx( |
| 255 | "flex h-7 w-7 items-center justify-center rounded-full font-mono text-[13px] tabular-nums", |
| 256 | isToday |
| 257 | ? "bg-signal text-ink" |
| 258 | : evCount |
| 259 | ? "text-paper" |
| 260 | : "text-ash-2" |
| 261 | )} |
| 262 | > |
| 263 | {day} |
| 264 | </span> |
| 265 | {evCount > 0 ? ( |
| 266 | <span className="mt-1.5 flex items-center gap-1.5" aria-hidden="true"> |
| 267 | <span className="flex flex-wrap gap-1"> |
| 268 | {marks.slice(0, 4).map((m, i) => ( |
| 269 | <span |
| 270 | key={i} |
| 271 | className="inline-block h-2 w-2 shrink-0 rounded-full" |
| 272 | style={{ backgroundColor: m.color }} |
| 273 | title={m.plain} |
| 274 | /> |
| 275 | ))} |
| 276 | </span> |
| 277 | <span className="label !text-[10px] tabular-nums text-ash-2"> |
| 278 | {evCount} |
| 279 | </span> |
| 280 | </span> |
| 281 | ) : null} |
| 282 | </Link> |
| 283 | </td> |
| 284 | ); |
| 285 | })} |
| 286 | </tr> |
| 287 | ))} |
| 288 | </tbody> |
| 289 | </table> |
| 290 | <p className="label border-t border-line px-3 py-2 text-ash-2"> |
| 291 | {loading |
| 292 | ? "Loading events…" |
| 293 | : `${filteredSummary.reduce((s, e) => s + e.count, 0)} event${filteredSummary.reduce((s, e) => s + e.count, 0) === 1 ? "" : "s"} this month. Select a day to read them.`} |
| 294 | </p> |
| 295 | </Card> |
| 296 | |
| 297 | {} |
| 298 | <div className="md:hidden"> |
| 299 | <MobileAgenda |
| 300 | daySummary={filteredSummary} |
| 301 | kindByValue={KIND_BY_VALUE} |
| 302 | selectedDay={selectedDay} |
| 303 | dayEvents={dayEvents} |
| 304 | year={year} |
| 305 | month={month} |
| 306 | kindFilter={kindFilter} |
| 307 | loading={loading} |
| 308 | /> |
| 309 | </div> |
| 310 | </div> |
| 311 | |
| 312 | {} |
| 313 | <div className="space-y-6"> |
| 314 | <Card className="p-5"> |
| 315 | <p className="label mb-3">Legend</p> |
| 316 | <ul className="space-y-2"> |
| 317 | {KINDS.filter((k, i) => KINDS.findIndex((x) => x.mark === k.mark) === i).map((k) => ( |
| 318 | <li key={k.value} className="flex items-center gap-2.5"> |
| 319 | <span |
| 320 | aria-hidden="true" |
| 321 | className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" |
| 322 | style={{ backgroundColor: k.color }} |
| 323 | /> |
| 324 | <span className="font-mono text-[12px] text-paper">{k.plain}</span> |
| 325 | <span className="label text-ash-2">{kindCounts.get(k.value) ?? 0}</span> |
| 326 | </li> |
| 327 | ))} |
| 328 | </ul> |
| 329 | <p className="label mt-4 text-ash-2"> |
| 330 | Colors are a cue only. Each mark carries its name, so the grid |
| 331 | reads clearly in Plain mode too. |
| 332 | </p> |
| 333 | </Card> |
| 334 | |
| 335 | <div> |
| 336 | <SectionHeader |
| 337 | eyebrow={selectedDay ? "Selected day" : "Pick a day"} |
| 338 | title={selectedDay ? formatLong(selectedDay) : "Day events"} |
| 339 | /> |
| 340 | {!selectedDay ? ( |
| 341 | <Card className="p-5"> |
| 342 | <p className="font-mono text-[13px] text-ash-2"> |
| 343 | Select a day in the grid to read its events. Each event links |
| 344 | to its official source. |
| 345 | </p> |
| 346 | </Card> |
| 347 | ) : dayEvents.length === 0 ? ( |
| 348 | <Card className="p-5"> |
| 349 | <p className="font-mono text-[13px] text-ash-2"> |
| 350 | No events on record for {formatLong(selectedDay)} |
| 351 | {kindFilter ? " for this filter" : ""}. |
| 352 | </p> |
| 353 | </Card> |
| 354 | ) : ( |
| 355 | <Card className="divide-y divide-[color:var(--dk-line)]"> |
| 356 | {dayEvents.map((e) => { |
| 357 | const k = KIND_BY_VALUE.get(e.kind); |
| 358 | return ( |
| 359 | <div key={e.id} className="p-4"> |
| 360 | <div className="flex flex-wrap items-center gap-2"> |
| 361 | {k ? ( |
| 362 | <span |
| 363 | className="pill" |
| 364 | style={{ |
| 365 | color: k.color, |
| 366 | borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`, |
| 367 | }} |
| 368 | > |
| 369 | {k.plain} |
| 370 | </span> |
| 371 | ) : ( |
| 372 | <span className="pill text-ash">{e.kind}</span> |
| 373 | )} |
| 374 | <span className="label text-ash-2">{e.refType}</span> |
| 375 | </div> |
| 376 | <p className="mt-2 font-mono text-[12px] leading-relaxed text-paper"> |
| 377 | {e.href ? ( |
| 378 | <Link |
| 379 | href={e.href} |
| 380 | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" |
| 381 | > |
| 382 | {e.title} |
| 383 | </Link> |
| 384 | ) : ( |
| 385 | e.title |
| 386 | )} |
| 387 | </p> |
| 388 | <div className="mt-2"> |
| 389 | <SourceLink url={e.sourceUrl} /> |
| 390 | </div> |
| 391 | </div> |
| 392 | ); |
| 393 | })} |
| 394 | </Card> |
| 395 | )} |
| 396 | </div> |
| 397 | </div> |
| 398 | </div> |
| 399 | </> |
| 400 | ); |
| 401 | } |
| 402 | |
| 403 | |
| 404 | function hrefFor( |
| 405 | ym: { year: number; month: number }, |
| 406 | day: string | undefined, |
| 407 | kind: string | undefined |
| 408 | ): string { |
| 409 | const params = new URLSearchParams(); |
| 410 | params.set("month", `${ym.year}-${pad(ym.month)}`); |
| 411 | if (day) params.set("day", day); |
| 412 | if (kind) params.set("kind", kind); |
| 413 | return `/calendar?${params.toString()}`; |
| 414 | } |
| 415 | |
| 416 | function todayYear(): number { |
| 417 | return new Date().getUTCFullYear(); |
| 418 | } |
| 419 | function todayMonth(): number { |
| 420 | return new Date().getUTCMonth() + 1; |
| 421 | } |
| 422 | |
| 423 | function chunk<T>(arr: T[], size: number): T[][] { |
| 424 | const out: T[][] = []; |
| 425 | for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); |
| 426 | return out; |
| 427 | } |
| 428 | |
| 429 | function distinctMarks(evs: CalendarDayKind[]): KindMeta[] { |
| 430 | const seen = new Set<string>(); |
| 431 | const out: KindMeta[] = []; |
| 432 | for (const e of evs) { |
| 433 | const m = KIND_BY_VALUE.get(e.kind); |
| 434 | if (m && !seen.has(m.mark)) { |
| 435 | seen.add(m.mark); |
| 436 | out.push(m); |
| 437 | } |
| 438 | } |
| 439 | return out; |
| 440 | } |
| 441 | |
| 442 | function dayEventCount(evs: CalendarDayKind[]): number { |
| 443 | return evs.reduce((s, e) => s + e.count, 0); |
| 444 | } |
| 445 | |
| 446 | function countKindsFromSummary(summary: CalendarDayKind[]): Map<CalendarKind, number> { |
| 447 | const m = new Map<CalendarKind, number>(); |
| 448 | for (const s of summary) m.set(s.kind, (m.get(s.kind) ?? 0) + s.count); |
| 449 | return m; |
| 450 | } |
| 451 | |
| 452 | function formatLong(iso: string): string { |
| 453 | const d = new Date(iso + "T00:00:00Z"); |
| 454 | if (Number.isNaN(d.getTime())) return iso; |
| 455 | return d.toLocaleDateString("en-US", { |
| 456 | timeZone: "UTC", |
| 457 | weekday: "long", |
| 458 | year: "numeric", |
| 459 | month: "long", |
| 460 | day: "numeric", |
| 461 | }); |
| 462 | } |
| 463 | |
| 464 | |
| 465 | function MobileAgenda({ |
| 466 | daySummary, |
| 467 | kindByValue, |
| 468 | selectedDay, |
| 469 | dayEvents, |
| 470 | year, |
| 471 | month, |
| 472 | kindFilter, |
| 473 | loading, |
| 474 | }: { |
| 475 | daySummary: CalendarDayKind[]; |
| 476 | kindByValue: Map<CalendarKind, KindMeta>; |
| 477 | selectedDay: string | undefined; |
| 478 | dayEvents: CalendarEvent[]; |
| 479 | year: number; |
| 480 | month: number; |
| 481 | kindFilter: string | undefined; |
| 482 | loading: boolean; |
| 483 | }) { |
| 484 | const byDate = new Map<string, CalendarDayKind[]>(); |
| 485 | for (const s of daySummary) { |
| 486 | const arr = byDate.get(s.date) ?? []; |
| 487 | arr.push(s); |
| 488 | byDate.set(s.date, arr); |
| 489 | } |
| 490 | const days = Array.from(byDate.keys()).sort(); |
| 491 | |
| 492 | if (loading) { |
| 493 | return ( |
| 494 | <Card className="p-5"> |
| 495 | <p className="font-mono text-[13px] text-ash-2">Loading events…</p> |
| 496 | </Card> |
| 497 | ); |
| 498 | } |
| 499 | |
| 500 | if (days.length === 0) { |
| 501 | return ( |
| 502 | <Card className="p-5"> |
| 503 | <p className="font-mono text-[13px] text-ash-2"> |
| 504 | No events on record for this month. Month navigation above moves to |
| 505 | other months; each event will link to its official source. |
| 506 | </p> |
| 507 | </Card> |
| 508 | ); |
| 509 | } |
| 510 | |
| 511 | return ( |
| 512 | <div className="space-y-2"> |
| 513 | {days.map((d) => { |
| 514 | const evs = byDate.get(d)!; |
| 515 | const count = dayEventCount(evs); |
| 516 | const marks = distinctMarks(evs); |
| 517 | const isSelected = d === selectedDay; |
| 518 | return ( |
| 519 | <section key={d}> |
| 520 | <Link |
| 521 | href={hrefFor({ year, month }, d, kindFilter)} |
| 522 | className={`focus-ring block rounded-[3px] border p-3 no-underline hover:bg-graphite/50 ${isSelected ? "border-paper bg-graphite" : "border-line"}`} |
| 523 | aria-current={isSelected ? "true" : undefined} |
| 524 | > |
| 525 | <div className="flex items-center justify-between gap-3"> |
| 526 | <span className="font-mono text-[13px] text-paper">{formatLong(d)}</span> |
| 527 | <span className="label text-ash-2 shrink-0">{count} event{count === 1 ? "" : "s"}</span> |
| 528 | </div> |
| 529 | <div className="mt-1.5 flex flex-wrap gap-1"> |
| 530 | {marks.map((m, i) => ( |
| 531 | <span |
| 532 | key={i} |
| 533 | className="pill !px-1.5 !py-0" |
| 534 | style={{ |
| 535 | color: m.color, |
| 536 | borderColor: `color-mix(in srgb, ${m.color} 45%, transparent)`, |
| 537 | }} |
| 538 | > |
| 539 | {m.plain} |
| 540 | </span> |
| 541 | ))} |
| 542 | </div> |
| 543 | </Link> |
| 544 | {isSelected && dayEvents.length > 0 && ( |
| 545 | <div className="mt-2 space-y-2 pl-3"> |
| 546 | {dayEvents.map((e) => { |
| 547 | const k = kindByValue.get(e.kind); |
| 548 | return ( |
| 549 | <article |
| 550 | key={e.id} |
| 551 | className="rounded-[3px] border border-line bg-surface p-3" |
| 552 | > |
| 553 | <div className="flex flex-wrap items-center gap-2"> |
| 554 | {k ? ( |
| 555 | <span |
| 556 | className="pill !py-0.5" |
| 557 | style={{ |
| 558 | color: k.color, |
| 559 | borderColor: `color-mix(in srgb, ${k.color} 45%, transparent)`, |
| 560 | }} |
| 561 | > |
| 562 | {k.plain} |
| 563 | </span> |
| 564 | ) : ( |
| 565 | <span className="pill !py-0.5 text-ash">{e.kind}</span> |
| 566 | )} |
| 567 | <span className="label text-ash-2">{e.refType}</span> |
| 568 | </div> |
| 569 | <p className="mt-2 break-words font-mono text-[12px] leading-relaxed text-paper"> |
| 570 | {e.href ? ( |
| 571 | <Link |
| 572 | href={e.href} |
| 573 | className="focus-ring rounded-sm underline underline-offset-4 hover:text-paper" |
| 574 | > |
| 575 | {e.title} |
| 576 | </Link> |
| 577 | ) : ( |
| 578 | e.title |
| 579 | )} |
| 580 | </p> |
| 581 | <div className="mt-2"> |
| 582 | <SourceLink url={e.sourceUrl} /> |
| 583 | </div> |
| 584 | </article> |
| 585 | ); |
| 586 | })} |
| 587 | </div> |
| 588 | )} |
| 589 | </section> |
| 590 | ); |
| 591 | })} |
| 592 | </div> |
| 593 | ); |
| 594 | } |