Slice 1: scaffolding + propose-to-super-draft vertical
Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the §5 schema (six numbered migrations), Gitea OAuth + §6 user provisioning, the §7 catalog left pane, and the propose-to-merge vertical: propose modal opens an idea PR against the meta repo, an owner merges from the pending-idea view, the cache picks it up via webhook or reconciler sweep, and the catalog renders the new super-draft. Per §1 the bot is the only Git writer; every commit, branch creation, and PR merge carries the §6.5 On-behalf-of: trailer and an `actions` audit row. Per §4 the cache is never written from a user action — it's webhook+reconciler only. Covered by `backend/tests/test_propose_vertical.py` against an in-process Gitea simulator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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 } from '../api'
|
||||
|
||||
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({ onProposeRFC, version }) {
|
||||
const [rfcs, setRfcs] = useState([])
|
||||
const [proposals, setProposals] = useState([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [sort, setSort] = useState('recent')
|
||||
const [activeChips, setActiveChips] = useState(new Set())
|
||||
const [pendingOpen, setPendingOpen] = useState(true)
|
||||
const { slug, prNumber } = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version])
|
||||
|
||||
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 (
|
||||
<aside className="catalog">
|
||||
<div className="catalog-search">
|
||||
<input
|
||||
placeholder="Search title, slug, ID…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="catalog-controls">
|
||||
<label htmlFor="catalog-sort">Sort:</label>
|
||||
<select id="catalog-sort" value={sort} onChange={e => setSort(e.target.value)}>
|
||||
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="catalog-chips">
|
||||
{STATE_CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.id}
|
||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||
onClick={() => toggleChip(chip.id)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="catalog-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||
{rfcs.length === 0
|
||||
? 'No RFCs in the catalog yet. Propose one below.'
|
||||
: 'No matches.'}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map(r => {
|
||||
const isActive = slug === r.slug
|
||||
const isSuper = r.state === 'super-draft'
|
||||
return (
|
||||
<Link
|
||||
key={r.slug}
|
||||
to={`/rfc/${r.slug}`}
|
||||
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
||||
>
|
||||
<div className="row-top">
|
||||
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
||||
{isSuper ? 'super-draft' : (r.id || '—')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="row-title">{r.title}</span>
|
||||
{r.tags.length > 0 && (
|
||||
<span className="row-tags">{r.tags.join(' · ')}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="catalog-pending">
|
||||
<button className="pending-header" onClick={() => setPendingOpen(o => !o)}>
|
||||
<span>{pendingOpen ? '▾' : '▸'} Pending ideas</span>
|
||||
<span className="pending-count">{proposals.length}</span>
|
||||
</button>
|
||||
{pendingOpen && proposals.length > 0 && (
|
||||
<div className="pending-list">
|
||||
{proposals.map(p => (
|
||||
<Link
|
||||
key={p.pr_number}
|
||||
to={`/proposals/${p.pr_number}`}
|
||||
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
||||
>
|
||||
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
||||
<div className="pending-by">by @{p.opened_by || '—'} · PR #{p.pr_number}</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pendingOpen && proposals.length === 0 && (
|
||||
<div style={{ padding: '0 14px 12px', color: '#aaa', fontSize: 12 }}>
|
||||
No pending proposals.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="catalog-footer">
|
||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user