v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
|
|
@ -1,5 +1,8 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { splitByStatus, useEvents } from "../../data/eventData.js";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
|
||||
import { eventHref } from "../../lib/hrefs.ts";
|
||||
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT LIST — CARDS
|
||||
|
|
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
|
|||
|
||||
<EventListCards section="national" view="carousel" />
|
||||
<EventListCards host="northwest" view="grid" />
|
||||
<EventListCards type={["class", "workshop"]} view="grid" />
|
||||
|
||||
`view` and `accent` come from the page's section manifest.
|
||||
|
||||
`type` pre-filters the band the way `section` and `host` do. Left
|
||||
off, the band takes every kind it finds and grows a row of chips
|
||||
to narrow by — but only once it holds more than one, so a band of
|
||||
nothing but retreats shows no control at all.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
|
||||
|
|
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
/* The title is the way into the event's own page.
|
||||
|
||||
A link on the title rather than a wrapper around the whole card:
|
||||
the footer already holds anchors, and an anchor inside an anchor
|
||||
is invalid markup that every browser resolves by guessing. The
|
||||
carousel has the same constraint — it needs the card's click to
|
||||
mean "bring this one to the front" on anything that isn't the
|
||||
active slide. */
|
||||
function TitleLink({ ev, linked, children }) {
|
||||
if (!linked) return <>{children}</>;
|
||||
return (
|
||||
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
EVENT CARD — one component, two sizes.
|
||||
compact=false → the full card used in the carousel
|
||||
|
|
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
|
|||
Fields arrive pre-resolved from the API — `color` is the event's
|
||||
own or its host's, `status` is derived from the dates when it
|
||||
isn't set — so nothing here reimplements those rules.
|
||||
|
||||
`linked` is the one thing a caller turns off: a card on the
|
||||
event's own page shouldn't link to the page it's already on.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function Card({
|
||||
ev,
|
||||
|
|
@ -126,6 +155,8 @@ export function Card({
|
|||
accent = TEAL,
|
||||
compact = false,
|
||||
interactive = true,
|
||||
linked = true,
|
||||
showType = false,
|
||||
}) {
|
||||
const past = ev.status === "past";
|
||||
const color = ev.color || defaultColor;
|
||||
|
|
@ -137,6 +168,19 @@ export function Card({
|
|||
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
|
||||
: null;
|
||||
|
||||
/* Off unless the caller says the band is mixed. A "Retreat" badge
|
||||
on every card in a row of nothing but retreats is noise, and
|
||||
the card can't tell on its own — it only ever sees one event. */
|
||||
const typeBadge =
|
||||
showType && ev.event_type ? (
|
||||
<span
|
||||
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
|
||||
style={{ border: `1px solid ${color}`, color }}
|
||||
>
|
||||
{eventTypeLabel(ev.event_type)}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
/* An ordered array, so a card can carry one paragraph or five
|
||||
without the component changing. */
|
||||
const descriptions = (ev.description ?? []).map((text, i) => (
|
||||
|
|
@ -168,7 +212,12 @@ export function Card({
|
|||
<div className="ev-grid mb-2">
|
||||
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
|
||||
<div className="ev-info">
|
||||
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
|
||||
{typeBadge}
|
||||
<h3 className="text-3xl font-900 leading-tight">
|
||||
<TitleLink ev={ev} linked={linked}>
|
||||
{ev.title}
|
||||
</TitleLink>
|
||||
</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
|
|
@ -190,7 +239,12 @@ export function Card({
|
|||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
|
||||
<h3 className="text-4xl font-900">{ev.title}</h3>
|
||||
{typeBadge}
|
||||
<h3 className="text-4xl font-900">
|
||||
<TitleLink ev={ev} linked={linked}>
|
||||
{ev.title}
|
||||
</TitleLink>
|
||||
</h3>
|
||||
{ev.theme && (
|
||||
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
|
||||
)}
|
||||
|
|
@ -211,10 +265,32 @@ export function Card({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Footer — mt-auto pins it to the bottom so buttons line up
|
||||
across every card in a grid row. */}
|
||||
{links.length > 0 ? (
|
||||
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
|
||||
{/* Footer — mt-auto pins the whole block to the bottom so
|
||||
buttons line up across every card in a grid row.
|
||||
|
||||
Three registration states, as before: links to follow, a
|
||||
past event, or an announcement still to come. What's new
|
||||
is that all three end in the same row, because every
|
||||
event now has a page and a past one is often the more
|
||||
worth reading — speakers, awards, what actually
|
||||
happened. The notice is what changes; the way in
|
||||
doesn't. */}
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
{links.length === 0 && (
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{past
|
||||
? "This event has concluded — thank you to everyone who joined us!"
|
||||
: igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex flex-wrap justify-center items-center ${
|
||||
compact ? "gap-2" : "gap-3"
|
||||
} ${links.length === 0 ? "mt-4" : ""}`}
|
||||
>
|
||||
{links.map(item => (
|
||||
<a
|
||||
key={item.label}
|
||||
|
|
@ -227,28 +303,17 @@ export function Card({
|
|||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : past ? (
|
||||
<p
|
||||
className="mt-auto text-center font-600 pt-8"
|
||||
style={{ color: accent }}
|
||||
>
|
||||
This event has concluded — thank you to everyone who joined us!
|
||||
</p>
|
||||
) : (
|
||||
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
|
||||
<p className="text-center font-600" style={{ color: accent }}>
|
||||
{igHandle
|
||||
? "Registration has not opened yet, follow our instagram for more details."
|
||||
: "Registration has not opened yet — check back soon for more details."}
|
||||
</p>
|
||||
{igHandle && (
|
||||
|
||||
{/* Only when there's nothing to register for and the
|
||||
event hasn't happened — the same condition as before,
|
||||
just no longer nested inside that branch. */}
|
||||
{igHandle && !past && links.length === 0 && (
|
||||
<a
|
||||
href={igUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
|
||||
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
|
||||
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
|
||||
compact ? "py-2.5 px-5" : "py-3 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${accent}`, color: accent }}
|
||||
>
|
||||
|
|
@ -256,13 +321,70 @@ export function Card({
|
|||
{igHandle}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Last, so Register reads first when there is one. */}
|
||||
{linked && (
|
||||
<Link
|
||||
to={eventHref(ev.id)}
|
||||
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
|
||||
compact ? "py-2.5 px-5" : "py-2.5 px-6"
|
||||
}`}
|
||||
style={{ border: `1px solid ${color}` }}
|
||||
>
|
||||
Event details
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TYPE FILTER
|
||||
|
||||
Drawn only when a band actually holds more than one kind, so it
|
||||
costs nothing today — every event is a retreat — and appears on
|
||||
its own the first time a class or a workshop lands in that band.
|
||||
Nothing on Retreats.tsx has to be reconfigured for it.
|
||||
|
||||
Chips rather than a select: with four or five options, all of
|
||||
them visible is one tap, and the row reads as what the section
|
||||
contains rather than as a form control.
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
export function TypeFilter({ types, active, setActive, accent }) {
|
||||
const chip = on => ({
|
||||
border: `1px solid ${accent}`,
|
||||
background: on ? accent : "transparent",
|
||||
color: on ? "#ffffff" : accent,
|
||||
});
|
||||
|
||||
const button = (id, label) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setActive(id)}
|
||||
aria-pressed={active === id}
|
||||
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
|
||||
style={chip(active === id)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
|
||||
style={{ maxWidth: GRID_MAX }}
|
||||
role="group"
|
||||
aria-label="Filter events by type"
|
||||
>
|
||||
{button("all", "All")}
|
||||
{types.map(entry => button(entry.id, entry.plural))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
TOGGLE — the control for the section heading's action bar
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
|
@ -323,22 +445,57 @@ export default function EventListCards({
|
|||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
view = "carousel",
|
||||
accent = TEAL,
|
||||
defaultColor,
|
||||
empty = "· Events coming soon, stay connected for announcements ·",
|
||||
}) {
|
||||
const { events, loading, error } = useEvents({ section, host, status });
|
||||
const { events: fetched, loading, error } = useEvents({
|
||||
section,
|
||||
host,
|
||||
status,
|
||||
type,
|
||||
});
|
||||
const cardColor = defaultColor ?? accent;
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const [showPast, setShowPast] = useState(false);
|
||||
const [activeType, setActiveType] = useState("all");
|
||||
|
||||
/* What this band holds, which is what the chips offer — not the
|
||||
full list of declared types, three quarters of which would be
|
||||
dead buttons. */
|
||||
const availableTypes = useMemo(
|
||||
() => typesPresent(fetched, EVENT_TYPES),
|
||||
[fetched],
|
||||
);
|
||||
|
||||
const mixed = availableTypes.length > 1;
|
||||
|
||||
const events = useMemo(
|
||||
() =>
|
||||
activeType === "all"
|
||||
? fetched
|
||||
: fetched.filter(e => e.event_type === activeType),
|
||||
[fetched, activeType],
|
||||
);
|
||||
|
||||
/* Open on the first upcoming event. The list is empty on the
|
||||
first render, so this can't be a useState initialiser — it has
|
||||
to wait for the data and then run once. Clearing the guard when
|
||||
the list empties means a refetch re-seeds. */
|
||||
const seeded = useRef(false);
|
||||
|
||||
/* Changing the chip is a different list, so the carousel re-seeds
|
||||
on the first upcoming event of that kind rather than holding an
|
||||
index that may now be past the end. Declared before the seed
|
||||
effect so the guard is already clear when it runs. */
|
||||
useEffect(() => {
|
||||
seeded.current = false;
|
||||
setIndex(0);
|
||||
}, [activeType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (events.length === 0) {
|
||||
seeded.current = false;
|
||||
|
|
@ -395,8 +552,24 @@ export default function EventListCards({
|
|||
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
{mixed && (
|
||||
<TypeFilter
|
||||
types={availableTypes}
|
||||
active={activeType}
|
||||
setActive={setActiveType}
|
||||
accent={accent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{events.length === 0 ? (
|
||||
notice(empty)
|
||||
/* Two different empties. Nothing scheduled is news; nothing
|
||||
of the kind you just picked is a filter you can undo, and
|
||||
the chips are still on screen to undo it with. */
|
||||
notice(
|
||||
activeType === "all"
|
||||
? empty
|
||||
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
|
||||
)
|
||||
) : view === "grid" ? (
|
||||
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
|
||||
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
|
||||
|
|
@ -412,6 +585,7 @@ export default function EventListCards({
|
|||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
compact
|
||||
showType={mixed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -450,6 +624,7 @@ export default function EventListCards({
|
|||
accent={accent}
|
||||
compact
|
||||
interactive={showPast}
|
||||
showType={mixed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -514,6 +689,7 @@ export default function EventListCards({
|
|||
defaultColor={cardColor}
|
||||
accent={accent}
|
||||
interactive={active}
|
||||
showType={mixed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
142
src/pages/sections/history/HistoryTimeline.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
defaultOpenYears,
|
||||
groupTimeline,
|
||||
groupYears,
|
||||
partitionByDate,
|
||||
type DecadeMeta,
|
||||
type SortDirection,
|
||||
type TimelineItem,
|
||||
} from '../../../lib/timeline'
|
||||
import TimelineDecade from './TimelineDecade'
|
||||
import TimelineUpcoming from './TimelineUpcoming'
|
||||
import './timeline.css'
|
||||
|
||||
const PARAM = 'y'
|
||||
|
||||
type Props = {
|
||||
items: TimelineItem[]
|
||||
decades: DecadeMeta[]
|
||||
direction?: SortDirection
|
||||
/** Decades ending before this year get the pre-program treatment. */
|
||||
programStartYear?: number
|
||||
fillGaps?: boolean
|
||||
/** Injectable for tests and for pinning a date in a screenshot. */
|
||||
now?: Date
|
||||
}
|
||||
|
||||
export default function HistoryTimeline({
|
||||
items,
|
||||
decades,
|
||||
direction = 'desc',
|
||||
programStartYear = 2002,
|
||||
fillGaps = true,
|
||||
now,
|
||||
}: Props) {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Upcoming vs recorded is a function of the clock, so an event crosses
|
||||
// over on its own date with nothing to flip.
|
||||
const { upcoming, past } = useMemo(
|
||||
() => partitionByDate(items, now ?? new Date()),
|
||||
[items, now],
|
||||
)
|
||||
|
||||
const upcomingYears = useMemo(
|
||||
() => groupYears(upcoming, { direction }),
|
||||
[upcoming, direction],
|
||||
)
|
||||
|
||||
const grouped = useMemo(
|
||||
() => groupTimeline(past, decades, { direction, programStartYear }),
|
||||
[past, decades, direction, programStartYear],
|
||||
)
|
||||
|
||||
// `?y=2014,2022` deep-links straight to open years. Absence of the
|
||||
// param means "not chosen yet", so fall back to the most recent
|
||||
// featured year; an empty param means everything was collapsed on
|
||||
// purpose.
|
||||
const openYears = useMemo(() => {
|
||||
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
|
||||
const raw = searchParams.get(PARAM) ?? ''
|
||||
return new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((part) => Number(part.trim()))
|
||||
.filter((n) => Number.isInteger(n)),
|
||||
)
|
||||
}, [searchParams, grouped])
|
||||
|
||||
const commit = useCallback(
|
||||
(next: Set<number>) => {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
|
||||
// replace: toggling shouldn't fill the back button with history.
|
||||
setSearchParams(params, { replace: true })
|
||||
},
|
||||
[searchParams, setSearchParams],
|
||||
)
|
||||
|
||||
const toggleYear = useCallback(
|
||||
(year: number) => {
|
||||
const next = new Set(openYears)
|
||||
if (next.has(year)) next.delete(year)
|
||||
else next.add(year)
|
||||
commit(next)
|
||||
},
|
||||
[openYears, commit],
|
||||
)
|
||||
|
||||
const allYears = useMemo(() => {
|
||||
const years = grouped.flatMap((decade) =>
|
||||
decade.years.filter((y) => y.count > 0).map((y) => y.year),
|
||||
)
|
||||
return [...years, ...upcomingYears.map((y) => y.year)]
|
||||
}, [grouped, upcomingYears])
|
||||
|
||||
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
|
||||
|
||||
if (grouped.length === 0 && upcomingYears.length === 0) {
|
||||
return (
|
||||
<div className="ngu-tl">
|
||||
<p className="ngu-tl__emptyState">
|
||||
The timeline is empty. Add a milestone to start the record.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ngu-tl">
|
||||
<div className="ngu-tl__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__toolbarBtn"
|
||||
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
|
||||
>
|
||||
{allOpen ? 'Collapse all years' : 'Expand all years'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TimelineUpcoming
|
||||
years={upcomingYears}
|
||||
count={upcoming.length}
|
||||
direction={direction}
|
||||
openYears={openYears}
|
||||
onToggleYear={toggleYear}
|
||||
/>
|
||||
|
||||
{grouped.map((decade) => (
|
||||
<TimelineDecade
|
||||
key={decade.decade}
|
||||
decade={decade}
|
||||
openYears={openYears}
|
||||
onToggleYear={toggleYear}
|
||||
direction={direction}
|
||||
fillGaps={fillGaps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
64
src/pages/sections/history/TimelineDecade.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {
|
||||
decadeLabel,
|
||||
withGapYears,
|
||||
type GroupedDecade,
|
||||
type SortDirection,
|
||||
} from '../../../lib/timeline'
|
||||
import TimelineYear from './TimelineYear'
|
||||
|
||||
type Props = {
|
||||
decade: GroupedDecade
|
||||
openYears: Set<number>
|
||||
onToggleYear: (year: number) => void
|
||||
direction?: SortDirection
|
||||
/** Show years with no records as muted nodes, so gaps stay visible. */
|
||||
fillGaps?: boolean
|
||||
}
|
||||
|
||||
export default function TimelineDecade({
|
||||
decade,
|
||||
openYears,
|
||||
onToggleYear,
|
||||
direction = 'desc',
|
||||
fillGaps = true,
|
||||
}: Props) {
|
||||
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
|
||||
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
|
||||
>
|
||||
<header className="ngu-tl__decadeHead">
|
||||
<span className="ngu-tl__decadeNum" aria-hidden="true">
|
||||
{decadeLabel(decade.decade)}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
|
||||
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
|
||||
{decade.title}
|
||||
</h3>
|
||||
{decade.tagline ? (
|
||||
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
|
||||
) : null}
|
||||
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
|
||||
{decade.preProgram ? (
|
||||
<p className="ngu-tl__gapNote">
|
||||
<span aria-hidden="true">◌</span>
|
||||
Before the program — kept for context, not part of our record
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{years.map((year) => (
|
||||
<TimelineYear
|
||||
key={year.year}
|
||||
year={year}
|
||||
open={openYears.has(year.year)}
|
||||
onToggle={onToggleYear}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
155
src/pages/sections/history/TimelineEntry.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
|
||||
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
|
||||
|
||||
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
|
||||
milestone: 'Milestone',
|
||||
event: 'Event',
|
||||
organization: 'Organization',
|
||||
award: 'Award',
|
||||
people: 'People',
|
||||
}
|
||||
|
||||
/** Day number only — the month node above already carries the month. */
|
||||
function dayMarker(item: TimelineItem): string {
|
||||
if (item.precision !== 'day') return ''
|
||||
const day = Number(item.date.split('-')[2])
|
||||
return Number.isFinite(day) ? String(day) : ''
|
||||
}
|
||||
|
||||
/** Featured entries sit above the month nodes, so they spell out their date. */
|
||||
function fullMarker(item: TimelineItem): string {
|
||||
const [, m, d] = item.date.split('-')
|
||||
if (item.precision === 'year' || !m) return 'Date unrecorded'
|
||||
const month = MONTH_LABELS[Number(m) - 1] ?? ''
|
||||
if (item.precision === 'month' || !d) return month
|
||||
return `${month} ${Number(d)}`
|
||||
}
|
||||
|
||||
/** Initials stand in when a person has no photo on file. */
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0] ?? '')
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
|
||||
function PersonChip({ person }: { person: PersonRef }) {
|
||||
const src = photoSrc(person.photo)
|
||||
return (
|
||||
<li className="ngu-tl__person">
|
||||
{src ? (
|
||||
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
|
||||
{initials(person.name)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ngu-tl__personName">
|
||||
{person.name}
|
||||
{person.title ? (
|
||||
<span className="ngu-tl__personTitle">{person.title}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
item: TimelineItem
|
||||
/** Featured entries render larger and lead the year. */
|
||||
variant?: 'inline' | 'featured'
|
||||
}
|
||||
|
||||
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
|
||||
const featured = variant === 'featured'
|
||||
const href = hrefFor(item)
|
||||
const logo = logoSrc(item)
|
||||
|
||||
const className = [
|
||||
'ngu-tl__entry',
|
||||
featured ? 'ngu-tl__entry--featured' : '',
|
||||
href ? 'ngu-tl__entry--linked' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const marker = (
|
||||
<span className="ngu-tl__entryDay">
|
||||
{featured ? fullMarker(item) : dayMarker(item)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const body = (
|
||||
<span className="ngu-tl__entryBody">
|
||||
<span className="ngu-tl__entryTitle">
|
||||
{logo ? (
|
||||
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span
|
||||
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
|
||||
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
|
||||
</span>
|
||||
)
|
||||
|
||||
// The people list sits outside the link: each name is its own
|
||||
// destination, and nesting anchors is invalid markup anyway.
|
||||
const roster =
|
||||
item.kind === 'people' && item.people && item.people.length > 0 ? (
|
||||
<div
|
||||
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
|
||||
>
|
||||
{item.team ? (
|
||||
<span className="ngu-tl__rosterTeam">
|
||||
{item.team.name}
|
||||
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
<ul className="ngu-tl__people">
|
||||
{item.people.map((person) => (
|
||||
<PersonChip key={person.id} person={person} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
|
||||
// An explicit href may point off-site; Link is for in-app routes only.
|
||||
const external = !!href && /^(https?:)?\/\//.test(href)
|
||||
|
||||
return (
|
||||
<li className="ngu-tl__entryWrap">
|
||||
{href && external ? (
|
||||
<a
|
||||
href={href}
|
||||
className={className}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{marker}
|
||||
{body}
|
||||
</a>
|
||||
) : href ? (
|
||||
<Link to={href} className={className} aria-label={label}>
|
||||
{marker}
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={className}>
|
||||
{marker}
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
{roster}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
69
src/pages/sections/history/TimelineUpcoming.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useState } from 'react'
|
||||
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
|
||||
import TimelineYear from './TimelineYear'
|
||||
|
||||
type Props = {
|
||||
years: GroupedYear[]
|
||||
count: number
|
||||
direction?: SortDirection
|
||||
openYears: Set<number>
|
||||
onToggleYear: (year: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Scheduled but not yet happened. Sits above the most recent decade and
|
||||
* opens upward: the toggle is first in the DOM and the list is rendered
|
||||
* after it, with `flex-direction: column-reverse` flipping the visual
|
||||
* order. That keeps the button anchored next to the decade marker
|
||||
* instead of drifting up the page as items appear.
|
||||
*
|
||||
* Membership is decided by date, not by a flag — see partitionByDate.
|
||||
* An event moves into the record on its own, with nothing to update.
|
||||
*/
|
||||
export default function TimelineUpcoming({
|
||||
years,
|
||||
count,
|
||||
direction = 'desc',
|
||||
openYears,
|
||||
onToggleYear,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (count === 0) return null
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
|
||||
aria-label="Upcoming"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__upcomingBtn"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
|
||||
<span className="ngu-tl__upcomingLabel">
|
||||
{open
|
||||
? 'Hide what’s scheduled'
|
||||
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
|
||||
</span>
|
||||
<span className="ngu-tl__chevron" aria-hidden="true">
|
||||
{open ? '▾' : '▴'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="ngu-tl__upcomingList">
|
||||
{years.map((year) => (
|
||||
<TimelineYear
|
||||
key={year.year}
|
||||
year={year}
|
||||
open={openYears.has(year.year)}
|
||||
onToggle={onToggleYear}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
90
src/pages/sections/history/TimelineYear.tsx
Normal file
90
src/pages/sections/history/TimelineYear.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
import type { GroupedYear } from '../../../lib/timeline'
|
||||
import TimelineEntry from './TimelineEntry'
|
||||
|
||||
/** Dot diameter scales with volume, so the rail reads as a density chart. */
|
||||
function dotSize(count: number): string {
|
||||
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
|
||||
}
|
||||
|
||||
type Props = {
|
||||
year: GroupedYear
|
||||
open: boolean
|
||||
onToggle: (year: number) => void
|
||||
}
|
||||
|
||||
export default function TimelineYear({ year, open, onToggle }: Props) {
|
||||
const empty = year.count === 0
|
||||
const panelId = `ngu-tl-year-${year.year}`
|
||||
|
||||
const className = [
|
||||
'ngu-tl__year',
|
||||
year.featured ? 'ngu-tl__year--featured' : '',
|
||||
open ? 'ngu-tl__year--open' : '',
|
||||
empty ? 'ngu-tl__year--empty' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<button
|
||||
type="button"
|
||||
className="ngu-tl__yearBtn"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
onClick={() => onToggle(year.year)}
|
||||
>
|
||||
<span
|
||||
className="ngu-tl__dot"
|
||||
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="ngu-tl__yearLabel">{year.year}</span>
|
||||
<span className="ngu-tl__yearCount">
|
||||
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
empty ? (
|
||||
<div className="ngu-tl__empty" id={panelId}>
|
||||
Nothing recorded for {year.year} yet.
|
||||
</div>
|
||||
) : (
|
||||
<div className="ngu-tl__panel" id={panelId}>
|
||||
{year.featuredItems.length > 0 ? (
|
||||
<ul className="ngu-tl__featured ngu-tl__list">
|
||||
{year.featuredItems.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} variant="featured" />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{year.months.map((month) => (
|
||||
<section className="ngu-tl__month" key={month.month}>
|
||||
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
|
||||
<ul className="ngu-tl__list">
|
||||
{month.items.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{year.undated.length > 0 ? (
|
||||
<section className="ngu-tl__month ngu-tl__undated">
|
||||
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
|
||||
<ul className="ngu-tl__list">
|
||||
{year.undated.map((item) => (
|
||||
<TimelineEntry key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
663
src/pages/sections/history/timeline.css
Normal file
663
src/pages/sections/history/timeline.css
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
/**
|
||||
* History timeline — scoped under .ngu-tl.
|
||||
*
|
||||
* Everything themeable is a custom property with a fallback, so rewiring to the
|
||||
* site tokens is a one-block edit at the top. Typefaces are deliberately not
|
||||
* set: the timeline inherits the page's type and only controls scale, weight
|
||||
* and tracking.
|
||||
*/
|
||||
|
||||
.ngu-tl {
|
||||
/* Defaults match the site palette; History.tsx overrides accent and
|
||||
surface per section so the rail sits on the right background. */
|
||||
--tl-ink: #16343a;
|
||||
--tl-muted: #4a6b72;
|
||||
--tl-faint: #8aa7ad;
|
||||
--tl-rail: #cfe4e8;
|
||||
--tl-surface: #ffffff;
|
||||
--tl-accent: #138ba0;
|
||||
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
|
||||
|
||||
--tl-gutter: 5rem;
|
||||
--tl-rail-x: 1.75rem;
|
||||
--tl-rail-w: 2px;
|
||||
|
||||
color: var(--tl-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ngu-tl {
|
||||
--tl-gutter: 3rem;
|
||||
--tl-rail-x: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Decade block ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__decade {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The rail. One per decade block so the pre-program run can change style
|
||||
without breaking the line's continuity. */
|
||||
.ngu-tl__decade::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: var(--tl-rail-x);
|
||||
width: var(--tl-rail-w);
|
||||
background: var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre::before {
|
||||
background: none;
|
||||
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Last decade fades out rather than stopping dead. */
|
||||
.ngu-tl__decade:last-child::before {
|
||||
bottom: 2rem;
|
||||
mask-image: linear-gradient(to bottom, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre {
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
/* ——— Decade header ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__decadeHead {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
padding-block: 3.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
|
||||
padding-block-start: 0.5rem;
|
||||
}
|
||||
|
||||
/* Numeral breaks the rail. */
|
||||
.ngu-tl__decadeNum {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
|
||||
padding-block: 0.35rem;
|
||||
background: var(--tl-surface);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.1;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: sideways;
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre .ngu-tl__decadeNum {
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__decadeTitle {
|
||||
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0;
|
||||
max-width: 22ch;
|
||||
}
|
||||
|
||||
.ngu-tl__decadeTagline {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.5;
|
||||
color: var(--tl-muted);
|
||||
max-width: 54ch;
|
||||
}
|
||||
|
||||
.ngu-tl__decadeBlurb {
|
||||
margin: 0.875rem 0 0;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.65;
|
||||
color: var(--tl-muted);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.ngu-tl__gapNote {
|
||||
margin: 1rem 0 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px dashed var(--tl-rail);
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Year node ———————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__year {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-block: 0.6875rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
|
||||
scrolling the page gives you the organization's density at a glance. */
|
||||
.ngu-tl__dot {
|
||||
position: relative;
|
||||
justify-self: start;
|
||||
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
|
||||
width: var(--tl-dot, 8px);
|
||||
height: var(--tl-dot, 8px);
|
||||
border-radius: 50%;
|
||||
background: var(--tl-rail);
|
||||
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||
transition: background-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__yearBtn:hover .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__year--featured .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__year--featured .ngu-tl__dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -5px;
|
||||
border: 1.5px solid var(--tl-accent);
|
||||
border-radius: 50%;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.ngu-tl__year--open .ngu-tl__dot {
|
||||
background: var(--tl-accent);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.ngu-tl__decade--pre .ngu-tl__dot {
|
||||
background: var(--tl-surface);
|
||||
border: 1.5px solid var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__yearLabel {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 550;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.01em;
|
||||
transition: color 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__yearCount {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--tl-faint);
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.ngu-tl__year--empty .ngu-tl__yearLabel {
|
||||
color: var(--tl-faint);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* ——— Expanded panel ——————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__panel {
|
||||
padding: 0.25rem 0 1.5rem var(--tl-gutter);
|
||||
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes ngu-tl-reveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.ngu-tl__month {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__month:first-child {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__monthLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 550;
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
.ngu-tl__monthLabel::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--tl-rail);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ngu-tl__undated .ngu-tl__monthLabel {
|
||||
font-style: italic;
|
||||
font-weight: 450;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* ——— Entry ————————————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__entry {
|
||||
display: grid;
|
||||
grid-template-columns: 2.75rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:hover {
|
||||
background: var(--tl-accent-soft);
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ngu-tl__entryDay {
|
||||
font-size: 0.8125rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--tl-faint);
|
||||
padding-top: 0.1875rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ngu-tl__entryTitle {
|
||||
display: block;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__entryMeta {
|
||||
display: block;
|
||||
margin-top: 0.125rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--tl-muted);
|
||||
}
|
||||
|
||||
.ngu-tl__entryBlurb {
|
||||
margin: 0.375rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
color: var(--tl-muted);
|
||||
max-width: 64ch;
|
||||
}
|
||||
|
||||
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
|
||||
calm and the type still carries the hierarchy. */
|
||||
.ngu-tl__kind {
|
||||
display: inline-block;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: 0.0625rem;
|
||||
background: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--milestone {
|
||||
border-radius: 50%;
|
||||
background: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--event {
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.ngu-tl__kind--award {
|
||||
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--person {
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Featured entries ————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__featured {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
padding: 1rem 1.125rem;
|
||||
border-left: 2px solid var(--tl-accent);
|
||||
background: var(--tl-accent-soft);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__entryTitle {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__entryDay {
|
||||
text-align: left;
|
||||
padding: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
a.ngu-tl__entry--featured:hover {
|
||||
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ——— Empty state ——————————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__empty {
|
||||
padding: 0.5rem 0 1.25rem var(--tl-gutter);
|
||||
font-size: 0.875rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ngu-tl *,
|
||||
.ngu-tl *::before,
|
||||
.ngu-tl *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Toolbar + utilities ————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--tl-rail);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--tl-muted);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn:hover {
|
||||
color: var(--tl-accent);
|
||||
border-color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__toolbarBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ngu-tl__emptyState {
|
||||
padding: 3rem 0;
|
||||
color: var(--tl-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__srOnly {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ——— Upcoming block ——————————————————————————————————————————
|
||||
Opens upward: the toggle is first in the DOM, column-reverse puts the
|
||||
list above it. Faded because none of it has happened yet. */
|
||||
|
||||
.ngu-tl__upcoming {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__upcoming::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
bottom: 0;
|
||||
left: var(--tl-rail-x);
|
||||
border-left: var(--tl-rail-w) dashed var(--tl-rail);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-block: 0.75rem;
|
||||
background: none;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
color: var(--tl-muted);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn:hover {
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingBtn:focus-visible {
|
||||
outline: 2px solid var(--tl-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingDot {
|
||||
justify-self: start;
|
||||
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px dashed var(--tl-accent);
|
||||
box-shadow: 0 0 0 4px var(--tl-surface);
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingLabel {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ngu-tl__chevron {
|
||||
font-size: 0.75rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Everything above the toggle is provisional, and reads that way. */
|
||||
.ngu-tl__upcomingList {
|
||||
opacity: 0.62;
|
||||
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingList:hover,
|
||||
.ngu-tl__upcomingList:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ngu-tl__upcomingList .ngu-tl__dot {
|
||||
background: var(--tl-surface);
|
||||
border: 1.5px dashed var(--tl-accent);
|
||||
}
|
||||
|
||||
@keyframes ngu-tl-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ——— Event / org / award logo ————————————————————————————————— */
|
||||
|
||||
.ngu-tl__logo {
|
||||
display: inline-block;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: -0.1875rem;
|
||||
object-fit: contain;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.ngu-tl__entry--featured .ngu-tl__logo {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
vertical-align: -0.3125rem;
|
||||
}
|
||||
|
||||
/* ——— People roster ——————————————————————————————————————————— */
|
||||
|
||||
.ngu-tl__entryWrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ngu-tl__roster {
|
||||
padding: 0.125rem 0 0.5rem 3.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__roster--featured {
|
||||
padding: 0.5rem 1.125rem 1rem;
|
||||
border-left: 2px solid var(--tl-accent);
|
||||
background: var(--tl-accent-soft);
|
||||
margin-top: -0.5rem;
|
||||
border-radius: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.ngu-tl__rosterTeam {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem 1rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ngu-tl__person {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ngu-tl__personPhoto {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--tl-accent-soft);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.ngu-tl__personPhoto--blank {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--tl-accent);
|
||||
}
|
||||
|
||||
.ngu-tl__personName {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.ngu-tl__personTitle {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--tl-faint);
|
||||
}
|
||||
|
||||
/* ——— Kind markers, continued ————————————————————————————————— */
|
||||
|
||||
.ngu-tl__kind--organization {
|
||||
border-radius: 2px;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__kind--people {
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
|
||||
}
|
||||
|
||||
.ngu-tl__entryBody {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue