// Catalog.jsx — the §7 left pane. // // One scrollable flat list of every super-draft and active entry, // state-styled rather than grouped. Filter chips are AND-combined per // §7.1; search is fuzzy over title + slug + id. The "Pending ideas" // disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC" // button. import { useEffect, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { listRFCs, listProposals, getCollection } from '../api' import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths' import JoinRequestModal from './JoinRequestModal.jsx' const STATE_CHIPS = [ { id: 'super-draft', label: 'Super-draft' }, { id: 'active', label: 'Active' }, ] const SORT_OPTIONS = [ { id: 'recent', label: 'Recently active' }, { id: 'title', label: 'Title' }, { id: 'id', label: 'ID' }, { id: 'state', label: 'State' }, ] export default function Catalog({ viewer, onProposeRFC, version }) { const [rfcs, setRfcs] = useState([]) const [proposals, setProposals] = useState([]) // §22 S4: the viewer's contribute capability in this collection, driving the // propose-first empty state (C3.5) and the propose control. `null` until the // collection's caps load; we fall back to "any authenticated viewer" so the // common case doesn't flicker, then refine. const [canContribute, setCanContribute] = useState(null) // §22.8: when the viewer can't contribute here but holds no role reaching the // collection, offer "Request to join" in the footer. const [canRequestJoin, setCanRequestJoin] = useState(false) const [joinOpen, setJoinOpen] = useState(false) // §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this // collection, read from the API. Defaults to the generic "RFC" until loaded. const [entryNoun, setEntryNoun] = useState('RFC') const [search, setSearch] = useState('') const [sort, setSort] = useState('recent') const [activeChips, setActiveChips] = useState(new Set()) const [pendingOpen, setPendingOpen] = useState(true) const { slug, prNumber } = useParams() const pid = useProjectId() // §22 S2: the catalog is scoped to the active collection (the `/c/:cid/` // route segment, else the project's default collection). const cid = useCollectionId() useEffect(() => { listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) setCanContribute(null) setCanRequestJoin(false) getCollection(pid, cid) .then(c => { setCanContribute(!!c?.viewer?.can_contribute) setCanRequestJoin(!!c?.viewer?.can_request_join) setEntryNoun(c?.entry_noun || 'RFC') }) .catch(() => setCanContribute(false)) }, [version, pid, cid]) // While caps load, fall back to "any authenticated viewer" so the propose // affordance on the common (default-collection) case doesn't flash off. const mayPropose = canContribute === null ? !!viewer : canContribute const filtered = useMemo(() => { const needle = search.trim().toLowerCase() let items = rfcs.filter(r => { if (activeChips.size > 0 && !activeChips.has(r.state)) return false if (!needle) return true const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase() return hay.includes(needle) }) items = [...items].sort((a, b) => { // Starred items pin to the top of the current sort per §7.2. if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1 if (sort === 'title') return a.title.localeCompare(b.title) if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz') if (sort === 'state') return a.state.localeCompare(b.state) // recent return (b.last_active_at || '').localeCompare(a.last_active_at || '') }) return items }, [rfcs, search, sort, activeChips]) function toggleChip(id) { const next = new Set(activeChips) next.has(id) ? next.delete(id) : next.add(id) setActiveChips(next) } return ( ) }