999c4b65ef
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>
164 lines
5.9 KiB
React
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>
|
|
)
|
|
}
|