v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -0,0 +1,438 @@
/* ═══════════════════════════════════════════════════════════════
ORGANIZATION DETAIL
/regions/:id /chapters/:id /partners/:id /organizations/:id
One component behind four routes, because organizations are one
table. The kind decides which extras render, not which file runs.
The URL kind is decoration — the slug is what identifies the
record — so a request for /chapters/great-lakes when that slug is
a region redirects to the canonical path rather than rendering a
correct page at a wrong address.
Leadership arrives flat with team_id and team_name on each row,
so grouping costs nothing. `teams` is fetched alongside anyway:
a team with no current public members would otherwise be
invisible here instead of listed, and its page unreachable.
═══════════════════════════════════════════════════════════════ */
import { Link, Navigate, useLocation, useParams } from 'react-router-dom'
import PageShell from '../components/PageShell.tsx'
import PageState from '../components/PageState.tsx'
import ContentBlocks from '../components/ContentBlocks.tsx'
import PeopleTiles from '../components/PeopleTiles.tsx'
import EventListCards from './sections/EventList-Cards.tsx'
import {
useOrganization,
type Leader,
type OrganizationRecord,
} from '../lib/useContent.ts'
import { orgHref, orgListHref, teamHref } from '../lib/hrefs.ts'
import { orgLogo, personPhoto } from '../lib/media.ts'
const TEAL = '#138ba0'
const BODY = '#4a6b72'
export default function OrganizationDetail() {
const { id } = useParams()
const { pathname } = useLocation()
const { data: org, loading, error, notFound, reload } = useOrganization(id)
if (!org) {
return (
<PageState
loading={loading}
error={error}
notFound={notFound}
onRetry={reload}
noun="organization"
backTo="/community"
backLabel="Community"
/>
)
}
// /chapters/x when x is a region: same record, wrong address.
//
// useLocation, not window.location: the latter is outside the
// router's awareness — always "/" under HashRouter, and free to
// be stale mid-navigation, either of which turns this into a
// redirect loop rather than a one-shot correction.
//
// Guarded on org.id because a record with no id would redirect to
// /chapters/undefined, which is a worse page than the one we're
// already on. All four organization routes must be registered or
// this redirects somewhere nothing matches.
const canonical = org.id ? orgHref(org.id, org.kind) : null
if (canonical && decodeURIComponent(pathname) !== canonical) {
return <Navigate to={canonical} replace />
}
const accent = org.color || TEAL
const back = orgListHref(org.kind)
const sections: any[] = [
{
id: 'about',
title: 'About',
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-8">
<Facts org={org} accent={accent} back={back} />
{org.description.map((paragraph, index) => (
<p key={index} className="leading-relaxed max-w-3xl" style={{ color: BODY }}>
{paragraph}
</p>
))}
<div className="max-w-3xl">
<ContentBlocks blocks={org.blocks} accent={accent} />
</div>
<Contact org={org} accent={accent} />
</div>
),
},
]
const teamBlocks = groupLeadership(org)
if (teamBlocks.length > 0) {
sections.push({
id: 'leadership',
title: 'Who runs it',
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6 space-y-12">
{teamBlocks.map((block) => (
<div key={block.key}>
<div className="mb-4">
{block.teamId ? (
<h3 className="text-xl font-bold">
<Link
to={teamHref(block.teamId)}
className="hover:underline"
style={{ color: accent }}
>
{block.label}
</Link>
</h3>
) : (
block.label && (
<h3 className="text-xl font-bold" style={{ color: accent }}>
{block.label}
</h3>
)
)}
{block.tagline && (
<p className="mt-1 text-sm" style={{ color: BODY }}>
{block.tagline}
</p>
)}
</div>
{block.people.length > 0 ? (
<PeopleTiles
size="lg"
accent={block.accent || accent}
people={block.people.map((leader) => ({
id: leader.person_id,
name: leader.display_name,
title: leader.title,
pronouns: leader.pronouns,
public_email: leader.public_email,
photo: personPhoto(leader.photo),
is_owner: leader.is_owner,
}))}
/>
) : (
<p className="text-sm italic" style={{ color: BODY }}>
No members listed yet.
</p>
)}
</div>
))}
</div>
),
})
}
if (org.kind === 'region' && (org.details.chapters?.length ?? 0) > 0) {
sections.push({
id: 'chapters',
title: 'Chapters',
blurb: org.details.map_note ?? undefined,
accent,
background: '#ffffff',
content: (
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{(org.details.chapters ?? []).map((chapter) => (
<Link
key={chapter.id}
to={orgHref(chapter.id, 'chapter')}
className="flex items-center gap-4 rounded-lg border p-4 transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: `${accent}40` }}
>
<Logo file={chapter.logo} name={chapter.name} accent={accent} />
<span>
<span className="block font-semibold" style={{ color: accent }}>
{chapter.name}
</span>
{chapter.location_label && (
<span className="block text-sm" style={{ color: BODY }}>
{chapter.location_label}
</span>
)}
</span>
</Link>
))}
</div>
),
})
}
if (org.awards.length > 0) {
sections.push({
id: 'awards',
title: 'Awards',
blurb: `Given by ${org.short_name || org.name}.`,
accent,
background: '#eef9fb',
content: (
<div className="max-w-6xl mx-auto px-6 grid gap-4 sm:grid-cols-2">
{org.awards.map((award) => (
<Link
key={award.id}
to={`/awards/${award.id}`}
className="rounded-lg border bg-white p-5 transition-colors hover:bg-[#f6fbfc]"
style={{ borderColor: `${accent}40` }}
>
<span className="block font-semibold" style={{ color: accent }}>
{award.name}
</span>
{award.description && (
<span className="mt-1 block text-sm" style={{ color: BODY }}>
{award.description}
</span>
)}
<span className="mt-3 block text-xs uppercase tracking-wide opacity-70" style={{ color: BODY }}>
{award.recipient_count === 0
? 'No recipients yet'
: `${award.recipient_count} recipient${award.recipient_count === 1 ? '' : 's'}`}
</span>
</Link>
))}
</div>
),
})
}
// EventList-Cards fetches and renders this itself — its own
// docstring offers `host` for exactly this case, and a card there
// already knows about gradients, logos, past-event collapsing and
// the carousel. A second grid here was the same component written
// worse.
//
// `org.events` is still what decides whether the section exists at
// all: an organization that has never hosted anything shouldn't
// get a heading followed by "events coming soon".
if (org.events.length > 0) {
sections.push({
id: 'events',
title: 'Gatherings',
accent,
background: '#ffffff',
content: (
<EventListCards
host={org.id}
view="grid"
accent={accent}
empty="· Nothing on the calendar right now ·"
/>
),
})
}
return <PageShell title={org.name} intro={org.tagline ?? undefined} sections={sections} />
}
/* ── Pieces ──────────────────────────────────────────────────── */
function Facts({
org,
accent,
back,
}: {
org: OrganizationRecord
accent: string
back: { to: string; label: string }
}) {
const where =
org.location_label || [org.locality, org.state_code].filter(Boolean).join(', ')
return (
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 text-sm">
{where && <span style={{ color: BODY }}>{where}</span>}
{org.kind === 'region' && org.details.scope && (
<span style={{ color: BODY }}>{org.details.scope}</span>
)}
{org.kind === 'chapter' && org.details.region_id && (
<span style={{ color: BODY }}>
Part of{' '}
<Link
to={orgHref(org.details.region_id, 'region')}
className="font-medium hover:underline"
style={{ color: org.details.region_color || accent }}
>
{org.details.region_name}
</Link>
</span>
)}
{org.kind === 'chapter' && org.details.meets && (
<span style={{ color: BODY }}>Meets {org.details.meets}</span>
)}
{org.kind === 'chapter' && org.details.started && (
<span style={{ color: BODY }}>Since {org.details.started}</span>
)}
{org.is_online && <span style={{ color: BODY }}>Online</span>}
<Link to={back.to} className="ml-auto hover:underline" style={{ color: accent }}>
{back.label}
</Link>
</div>
)
}
function Contact({ org, accent }: { org: OrganizationRecord; accent: string }) {
const hasAny =
org.links.length > 0 || org.socials.length > 0 || org.website || org.email
if (!hasAny) return null
return (
<div className="flex flex-wrap gap-3">
{org.links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full px-5 py-2 text-sm font-semibold text-white transition-transform hover:scale-105"
style={{ background: accent }}
>
{link.label}
</a>
))}
{[org.website, org.email, ...org.socials]
.filter((link): link is NonNullable<typeof link> => Boolean(link))
.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noreferrer"
className="rounded-full border px-4 py-1.5 text-sm font-medium transition-colors hover:bg-[#eef9fb]"
style={{ borderColor: accent, color: accent }}
>
{link.label}
</a>
))}
</div>
)
}
function Logo({
file,
name,
accent,
}: {
file?: string | null
name: string
accent: string
}) {
const src = orgLogo(file)
if (!src) {
return (
<span
aria-hidden="true"
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style={{ background: accent }}
>
{name.slice(0, 2).toUpperCase()}
</span>
)
}
return <img src={src} alt="" loading="lazy" className="h-10 w-10 shrink-0 object-contain" />
}
/* ── Leadership → team blocks ────────────────────────────────── */
type TeamBlock = {
key: string
teamId: string | null
label: string | null
tagline?: string | null
accent?: string | null
people: Leader[]
}
/* Order comes from `teams` (the admin's sort_order), not from the
order people happen to appear in. Three cases to cover:
affiliations with no team at all, teams with nobody in them, and
people filed under a team that is no longer published. */
function groupLeadership(org: OrganizationRecord): TeamBlock[] {
const byTeam = new Map<string, Leader[]>()
const loose: Leader[] = []
for (const leader of org.leadership) {
if (!leader.team_id) {
loose.push(leader)
continue
}
const list = byTeam.get(leader.team_id)
if (list) list.push(leader)
else byTeam.set(leader.team_id, [leader])
}
const blocks: TeamBlock[] = []
// People who hold a role in the organization without sitting on a
// team. Usually the leads. No heading — they are the page.
if (loose.length > 0) {
blocks.push({ key: 'loose', teamId: null, label: null, people: loose })
}
for (const team of org.teams) {
blocks.push({
key: team.id,
teamId: team.id,
label: team.name,
tagline: team.tagline,
accent: team.color,
people: byTeam.get(team.id) ?? [],
})
byTeam.delete(team.id)
}
// Whatever is left is filed under an unpublished team. Its page
// isn't reachable, so the heading is plain text — but the people
// are real and shouldn't silently vanish from the org.
for (const [teamId, people] of byTeam) {
blocks.push({
key: teamId,
teamId: null,
label: people[0]?.team_name ?? null,
people,
})
}
return blocks
}