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
438
src/pages/OrganizationDetail.tsx
Normal file
438
src/pages/OrganizationDetail.tsx
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue