Files
rfc-app/frontend/src/components/Catalog.jsx
T
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00

164 lines
5.9 KiB
React

// 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'
import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths'
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([])
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()
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
? (viewer ? 'No RFCs in the catalog yet. Propose one below.' : 'No RFCs in the catalog yet.')
: 'No matches.'}
</div>
) : (
filtered.map(r => {
const isActive = slug === r.slug
const isSuper = r.state === 'super-draft'
return (
<Link
key={r.slug}
to={entryPath(pid, 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={proposalPath(pid, 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">
{viewer ? (
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
) : (
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
Sign in to propose <span className="beta-chip">Beta</span>
</a>
)}
</div>
</aside>
)
}