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,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wiggleverse RFCs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1707
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-placeholder": "^3.5.0",
|
||||
"@tiptap/pm": "^3.5.0",
|
||||
"@tiptap/react": "^3.5.0",
|
||||
"@tiptap/starter-kit": "^3.5.0",
|
||||
"marked": "^18.0.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
/* Adapted from the prototype's App.css per §18, narrowed to slice-1 surfaces. */
|
||||
|
||||
.boot {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; color: #888; font-size: 14px;
|
||||
}
|
||||
|
||||
.app { height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
.app-header {
|
||||
height: 48px; flex-shrink: 0;
|
||||
background: #1a1a1a; color: #fff;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.app-brand a { color: #fff; text-decoration: none; font-weight: 600; font-size: 14px; }
|
||||
.header-right { display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
||||
.user-name { color: #ddd; }
|
||||
.user-role-badge {
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
.role-owner { background: #b45309; }
|
||||
.role-admin { background: #4338ca; }
|
||||
|
||||
.btn-link {
|
||||
color: #fff; text-decoration: none;
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 6px; padding: 4px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-link:hover { background: rgba(255,255,255,0.25); }
|
||||
|
||||
.app-body { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
/* --- Catalog (left pane, §7) --- */
|
||||
|
||||
.catalog {
|
||||
width: 320px; flex-shrink: 0;
|
||||
background: #fff; border-right: 1px solid #e5e5e5;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.catalog-search {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
}
|
||||
.catalog-search input {
|
||||
width: 100%; border: 1px solid #e5e5e5; border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 13px; outline: none;
|
||||
}
|
||||
.catalog-search input:focus { border-color: #1a1a1a; }
|
||||
|
||||
.catalog-controls {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
font-size: 12px; color: #555;
|
||||
}
|
||||
.catalog-controls select {
|
||||
border: 1px solid #e5e5e5; border-radius: 4px;
|
||||
font-size: 12px; padding: 2px 4px;
|
||||
}
|
||||
|
||||
.catalog-chips {
|
||||
display: flex; flex-wrap: wrap; gap: 4px;
|
||||
padding: 6px 14px 10px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
}
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
background: #f0f0ee; color: #444;
|
||||
border: 1px solid transparent; border-radius: 999px;
|
||||
padding: 2px 9px; cursor: pointer;
|
||||
}
|
||||
.chip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
|
||||
|
||||
.catalog-list { flex: 1; overflow-y: auto; padding: 4px 0; }
|
||||
|
||||
.catalog-row {
|
||||
display: flex; flex-direction: column;
|
||||
padding: 8px 14px;
|
||||
border: none; background: none; text-align: left; cursor: pointer;
|
||||
border-left: 3px solid transparent; width: 100%;
|
||||
}
|
||||
.catalog-row:hover { background: #f7f7f5; }
|
||||
.catalog-row.active { background: #f0f0ee; border-left-color: #1a1a1a; }
|
||||
.catalog-row .row-top { display: flex; align-items: center; gap: 6px; }
|
||||
.row-id {
|
||||
font-size: 10px; font-weight: 700;
|
||||
color: #888; text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.row-id.super { color: #b45309; }
|
||||
.row-title { font-size: 13px; margin-top: 2px; }
|
||||
.catalog-row.is-super .row-title { color: #555; }
|
||||
.row-tags { font-size: 11px; color: #999; margin-top: 2px; }
|
||||
|
||||
.catalog-pending {
|
||||
border-top: 1px solid #f0f0ee;
|
||||
flex-shrink: 0;
|
||||
background: #fafaf8;
|
||||
}
|
||||
.pending-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
font-size: 11px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
background: none; border: none; width: 100%; text-align: left;
|
||||
}
|
||||
.pending-count {
|
||||
background: #e5e5e5; color: #333;
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px; font-size: 11px;
|
||||
}
|
||||
.pending-list { padding: 2px 0 8px; }
|
||||
.pending-row {
|
||||
display: block;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px; color: #444; text-decoration: none;
|
||||
cursor: pointer; background: none; border: none; text-align: left; width: 100%;
|
||||
}
|
||||
.pending-row:hover { background: #fff; color: #1a1a1a; }
|
||||
.pending-row.active { background: #fff; color: #1a1a1a; }
|
||||
.pending-row .pending-by {
|
||||
font-size: 11px; color: #999; margin-top: 1px;
|
||||
}
|
||||
|
||||
.catalog-footer {
|
||||
border-top: 1px solid #e5e5e5;
|
||||
padding: 10px 14px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.btn-propose {
|
||||
width: 100%;
|
||||
background: #1a1a1a; color: #fff;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-propose:hover { background: #333; }
|
||||
.btn-propose:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* --- Main pane --- */
|
||||
|
||||
.main-pane {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 32px 48px;
|
||||
}
|
||||
|
||||
.welcome { max-width: 640px; }
|
||||
.welcome h1 { font-size: 22px; font-weight: 600; margin: 0 0 16px; }
|
||||
.welcome p { line-height: 1.7; color: #444; }
|
||||
|
||||
/* --- RFC / Proposal view (read-only for slice 1) --- */
|
||||
|
||||
.entry-view { max-width: 720px; margin: 0 auto; }
|
||||
.entry-state-banner {
|
||||
font-size: 12px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
color: #b45309;
|
||||
padding: 6px 10px;
|
||||
background: #fffbeb; border: 1px solid #fde68a;
|
||||
border-radius: 6px;
|
||||
display: inline-block; margin-bottom: 16px;
|
||||
}
|
||||
.entry-state-banner.active { color: #166534; background: #f0fdf4; border-color: #bbf7d0; }
|
||||
.entry-state-banner.declined { color: #991b1b; background: #fef2f2; border-color: #fecaca; }
|
||||
.entry-state-banner.merged { color: #1e40af; background: #eff6ff; border-color: #bfdbfe; }
|
||||
|
||||
.entry-title { font-size: 26px; font-weight: 700; margin: 0 0 8px; }
|
||||
.entry-meta { font-size: 12px; color: #999; margin-bottom: 24px; }
|
||||
.entry-meta .entry-tag {
|
||||
display: inline-block;
|
||||
background: #f0f0ee; color: #555;
|
||||
padding: 1px 8px; border-radius: 999px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.entry-body {
|
||||
font-size: 15px; line-height: 1.75; color: #1a1a1a;
|
||||
}
|
||||
.entry-body h1, .entry-body h2, .entry-body h3 { font-weight: 600; }
|
||||
.entry-body h1 { font-size: 22px; margin-top: 24px; }
|
||||
.entry-body h2 { font-size: 17px; margin-top: 20px; }
|
||||
.entry-body h3 { font-size: 15px; margin-top: 16px; }
|
||||
.entry-body p { margin: 0 0 12px; }
|
||||
.entry-body ul, .entry-body ol { padding-left: 24px; }
|
||||
.entry-body code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
|
||||
|
||||
.entry-actions {
|
||||
display: flex; gap: 8px; margin: 16px 0 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #166534; color: #fff;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { background: #14532d; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-secondary {
|
||||
background: #fff; color: #1a1a1a;
|
||||
border: 1px solid #d4d4d4; border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: #f7f7f5; }
|
||||
.btn-danger {
|
||||
background: #fff; color: #991b1b;
|
||||
border: 1px solid #fecaca; border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
font-size: 13px; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover { background: #fef2f2; }
|
||||
|
||||
/* --- Modals --- */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(20, 20, 20, 0.4);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
||||
width: 560px; max-width: calc(100vw - 40px);
|
||||
max-height: calc(100vh - 80px);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.modal-header h2 { margin: 0; font-size: 17px; font-weight: 600; }
|
||||
.modal-close {
|
||||
background: none; border: none; font-size: 22px; cursor: pointer;
|
||||
color: #999; line-height: 1; padding: 0;
|
||||
}
|
||||
.modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.modal-body label {
|
||||
display: block;
|
||||
font-size: 12px; font-weight: 600;
|
||||
color: #555;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.modal-body input, .modal-body textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #d4d4d4; border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px; font-family: inherit;
|
||||
outline: none; margin-bottom: 14px;
|
||||
}
|
||||
.modal-body input:focus, .modal-body textarea:focus { border-color: #1a1a1a; }
|
||||
.modal-body textarea { resize: vertical; min-height: 100px; }
|
||||
.field-help {
|
||||
font-size: 12px; color: #999;
|
||||
margin-top: -10px; margin-bottom: 14px;
|
||||
}
|
||||
.field-error {
|
||||
font-size: 12px; color: #991b1b;
|
||||
margin-top: -10px; margin-bottom: 14px;
|
||||
}
|
||||
.modal-actions {
|
||||
border-top: 1px solid #f0f0ee;
|
||||
padding: 14px 20px;
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
}
|
||||
|
||||
/* --- Landing page (pre-login, §14.1) --- */
|
||||
|
||||
.landing {
|
||||
height: 100vh;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.landing h1 { font-size: 28px; font-weight: 700; margin: 0 0 8px; }
|
||||
.landing .subtitle { font-size: 16px; color: #666; margin: 0 0 28px; }
|
||||
.landing .pitch {
|
||||
max-width: 540px;
|
||||
line-height: 1.7; color: #333;
|
||||
font-size: 15px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.landing .btn-signin {
|
||||
background: #1a1a1a; color: #fff;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px; font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.landing .btn-signin:hover { background: #333; }
|
||||
.landing .secondary-link {
|
||||
margin-top: 14px;
|
||||
font-size: 13px;
|
||||
color: #666; text-decoration: none;
|
||||
}
|
||||
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
const [me, setMe] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [proposeOpen, setProposeOpen] = useState(false)
|
||||
const [catalogVersion, setCatalogVersion] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then(setMe)
|
||||
.catch(() => setMe({ authenticated: false }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
|
||||
if (!me?.authenticated) {
|
||||
return <Landing />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<div className="app-brand">
|
||||
<Link to="/">Wiggleverse RFCs</Link>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
</div>
|
||||
</header>
|
||||
<div className="app-body">
|
||||
<Catalog
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
version={catalogVersion}
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
{proposeOpen && (
|
||||
<ProposeModal
|
||||
onClose={() => setProposeOpen(false)}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(`/proposals/${pr_number}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome({ viewer }) {
|
||||
return (
|
||||
<div className="welcome">
|
||||
<h1>Welcome, {viewer.display_name}.</h1>
|
||||
<p>
|
||||
The catalog on the left lists every super-draft and active RFC in the
|
||||
framework. Open one to read the canonical body, or use{' '}
|
||||
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
|
||||
idea PR against the meta repository.
|
||||
</p>
|
||||
<p>
|
||||
Slice 1 of the build is in place: propose → idea PR → owner merges →
|
||||
super-draft appears in the catalog → super-draft view renders. The
|
||||
revision flow, per-branch chat, AI participation, and the PR surface
|
||||
land in subsequent slices.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// api.js — every backend call lives here.
|
||||
//
|
||||
// All write requests pass {credentials: 'include'} implicitly because
|
||||
// the dev proxy and the production deploy serve the API from the same
|
||||
// origin as the frontend. If you split origins later, change here.
|
||||
|
||||
async function jsonOrThrow(res) {
|
||||
if (!res.ok) {
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await res.json()
|
||||
detail = body.detail || JSON.stringify(body)
|
||||
} catch {
|
||||
detail = await res.text()
|
||||
}
|
||||
const error = new Error(detail || `HTTP ${res.status}`)
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function getMe() {
|
||||
const res = await fetch('/api/auth/me')
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function listRFCs() {
|
||||
return jsonOrThrow(await fetch('/api/rfcs'))
|
||||
}
|
||||
|
||||
export async function getRFC(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
export async function listProposals() {
|
||||
return jsonOrThrow(await fetch('/api/proposals'))
|
||||
}
|
||||
|
||||
export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function declineProposal(prNumber, comment) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/decline`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function withdrawProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Landing.jsx — §14.1's pre-login surface.
|
||||
//
|
||||
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
|
||||
// single primary action: "Sign in with Gitea." The visual design is
|
||||
// deferred per §14.4; the structural commitments are here.
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className="landing">
|
||||
<h1>Wiggleverse RFCs</h1>
|
||||
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
|
||||
<p className="pitch">
|
||||
Large language models work brilliantly with programming languages because every
|
||||
word in Python or C has a definitive meaning enforced by tooling. They struggle
|
||||
with natural language because no such dictionary exists for words like
|
||||
<em> consent</em>, <em> trait</em>, or <em> agency</em> — words that do enormous
|
||||
work in any system that interacts with humans. The Wiggleverse RFC framework is
|
||||
a standardization process for that vocabulary. Build the dictionary first.
|
||||
</p>
|
||||
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
|
||||
<a className="secondary-link" href="/philosophy">Read the full philosophy →</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// ProposalView.jsx — §9.3 pending-idea view.
|
||||
//
|
||||
// Renders the proposed entry's body and frontmatter (read-only) with a
|
||||
// status banner ("Pending idea — awaiting review"). The header strip
|
||||
// carries Merge / Decline / Withdraw per the viewer's affordances. The
|
||||
// decline two-step composer-then-preview from §9.3 ships in Slice 1
|
||||
// as a single-step required-comment input; the preview-confirm ceremony
|
||||
// can land with the rest of §9.3's UX polish in the §19.2 "pending-idea
|
||||
// view's interaction design (remainder)" topic.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
|
||||
|
||||
export default function ProposalView({ viewer, onChange }) {
|
||||
const { prNumber } = useParams()
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [acting, setActing] = useState(false)
|
||||
const [declineOpen, setDeclineOpen] = useState(false)
|
||||
const [declineComment, setDeclineComment] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
function refresh() {
|
||||
setData(null); setError(null)
|
||||
getProposal(prNumber).then(setData).catch(err => setError(err.message))
|
||||
}
|
||||
|
||||
useEffect(refresh, [prNumber])
|
||||
|
||||
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
|
||||
if (!data) return <div className="entry-view">Loading…</div>
|
||||
|
||||
const isOpen = data.state === 'open'
|
||||
|
||||
async function doMerge() {
|
||||
setActing(true)
|
||||
try {
|
||||
const { slug } = await mergeProposal(prNumber)
|
||||
onChange?.()
|
||||
navigate(`/rfc/${slug}`)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doDecline() {
|
||||
if (!declineComment.trim()) return
|
||||
setActing(true)
|
||||
try {
|
||||
await declineProposal(prNumber, declineComment.trim())
|
||||
onChange?.()
|
||||
refresh()
|
||||
setDeclineOpen(false)
|
||||
setDeclineComment('')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function doWithdraw() {
|
||||
if (!confirm('Withdraw this proposal? The PR will be closed; the conversation stays attached as historical record.')) return
|
||||
setActing(true)
|
||||
try {
|
||||
await withdrawProposal(prNumber)
|
||||
onChange?.()
|
||||
refresh()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className={`entry-state-banner ${data.state === 'merged' ? 'merged' : data.state === 'open' ? '' : 'declined'}`}>
|
||||
{isOpen ? 'Pending idea — awaiting review'
|
||||
: data.state === 'merged' ? 'Merged — now a super-draft'
|
||||
: 'Closed'}
|
||||
</div>
|
||||
|
||||
<h1 className="entry-title">
|
||||
{data.entry?.title || data.title.replace(/^Propose:\s*/, '')}
|
||||
</h1>
|
||||
|
||||
<div className="entry-meta">
|
||||
<span>PR #{data.pr_number}</span>
|
||||
{data.opened_by && <> · proposed by <strong>@{data.opened_by}</strong></>}
|
||||
{data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}</>}
|
||||
{data.entry?.tags?.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{data.entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="entry-actions">
|
||||
{data.affordances.merge && (
|
||||
<button className="btn-primary" onClick={doMerge} disabled={acting}>
|
||||
{acting ? 'Merging…' : 'Merge proposal'}
|
||||
</button>
|
||||
)}
|
||||
{data.affordances.decline && (
|
||||
<button className="btn-danger" onClick={() => setDeclineOpen(true)} disabled={acting}>
|
||||
Decline
|
||||
</button>
|
||||
)}
|
||||
{data.affordances.withdraw && (
|
||||
<button className="btn-secondary" onClick={doWithdraw} disabled={acting}>
|
||||
Withdraw proposal
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{declineOpen && (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) setDeclineOpen(false) }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Decline proposal</h2>
|
||||
<button className="modal-close" onClick={() => setDeclineOpen(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>Comment to the proposer (required)</label>
|
||||
<textarea
|
||||
value={declineComment}
|
||||
onChange={e => setDeclineComment(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="The proposer will read this verbatim."
|
||||
autoFocus
|
||||
/>
|
||||
<p className="field-help">
|
||||
Per §9.3, the decline ceremony's two-step preview-and-confirm
|
||||
surface lands with the rest of §9.3 UX in Slice 2. For now the
|
||||
comment goes directly to the PR and to the proposer's inbox.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={() => setDeclineOpen(false)}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={doDecline}
|
||||
disabled={!declineComment.trim() || acting}
|
||||
>
|
||||
{acting ? 'Declining…' : 'Send decline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
|
||||
Proposed entry
|
||||
</h3>
|
||||
<div
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// ProposeModal.jsx — §9.1.
|
||||
//
|
||||
// Title (required) and pitch (required textarea), with a slug field
|
||||
// that auto-fills from the title via the same deterministic kebab-case
|
||||
// the backend uses. Tags are chip-input (free-form for slice 1; the
|
||||
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
|
||||
// is wired up).
|
||||
//
|
||||
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
|
||||
// success navigates the proposer to the pending-idea view per §9.3.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { proposeRFC } from '../api'
|
||||
|
||||
function slugify(title) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ onClose, onSubmitted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const [tags, setTags] = useState([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slugEdited) setSlug(slugify(title))
|
||||
}, [title, slugEdited])
|
||||
|
||||
function addTag() {
|
||||
const t = tagInput.trim()
|
||||
if (t && !tags.includes(t)) setTags([...tags, t])
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !slug || !pitch.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await proposeRFC({
|
||||
title: title.trim(),
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
tags,
|
||||
})
|
||||
onSubmitted?.(result)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Submission failed.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Propose a New RFC</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<label htmlFor="propose-title">Title</label>
|
||||
<input
|
||||
id="propose-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="e.g. Open Human Model"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<p className="field-help">The word or topic this RFC will define.</p>
|
||||
|
||||
<label htmlFor="propose-slug">Slug</label>
|
||||
<input
|
||||
id="propose-slug"
|
||||
value={slug}
|
||||
onChange={e => { setSlug(slugify(e.target.value)); setSlugEdited(true) }}
|
||||
placeholder="open-human-model"
|
||||
required
|
||||
/>
|
||||
<p className="field-help">Auto-derived from the title; edit if needed.</p>
|
||||
|
||||
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
|
||||
<textarea
|
||||
id="propose-pitch"
|
||||
value={pitch}
|
||||
onChange={e => setPitch(e.target.value)}
|
||||
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
|
||||
rows={5}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||
<input
|
||||
id="propose-tag"
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag() }
|
||||
}}
|
||||
placeholder="identity, schema"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<button type="button" className="btn-secondary" onClick={addTag} style={{ padding: '6px 12px' }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div style={{ marginTop: 4, marginBottom: 14 }}>
|
||||
{tags.map(t => (
|
||||
<span key={t} className="entry-tag" style={{ display: 'inline-block', marginRight: 4 }}>
|
||||
{t}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTags(tags.filter(x => x !== t))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#999' }}
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!title.trim() || !slug || !pitch.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Opening PR…' : 'Open proposal PR'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// RFCView.jsx — §9.4 super-draft view (and a stub for active RFCs).
|
||||
//
|
||||
// Slice 1 ships read-only body rendering: the breadcrumb names the
|
||||
// entry, the body renders via marked. The discuss-vs-contribute toggle,
|
||||
// per-branch chat, change-card panel, and breadcrumb dropdown all land
|
||||
// in Slice 2 per §8.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { getRFC } from '../api'
|
||||
|
||||
export default function RFCView() {
|
||||
const { slug } = useParams()
|
||||
const [entry, setEntry] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(null); setError(null)
|
||||
getRFC(slug).then(setEntry).catch(err => setError(err.message))
|
||||
}, [slug])
|
||||
|
||||
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
|
||||
if (!entry) return <div className="entry-view">Loading…</div>
|
||||
|
||||
const stateClass = entry.state === 'active' ? 'active' : ''
|
||||
return (
|
||||
<article className="entry-view">
|
||||
<div className={`entry-state-banner ${stateClass}`}>
|
||||
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
|
||||
</div>
|
||||
<h1 className="entry-title">{entry.title}</h1>
|
||||
<div className="entry-meta">
|
||||
<span>{entry.slug}</span>
|
||||
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>}
|
||||
{entry.proposed_at && <> · {entry.proposed_at}</>}
|
||||
{entry.tags.length > 0 && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.state === 'active' && (
|
||||
<div className="entry-state-banner" style={{ background: '#fffbeb', borderColor: '#fde68a', color: '#92400e' }}>
|
||||
The active-RFC view (editor, branches, chat) lands in Slice 2.
|
||||
The body below is the canonical main-branch text.
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="entry-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
|
||||
/>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
color: #1a1a1a;
|
||||
background: #fafaf8;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; }
|
||||
a { color: inherit; }
|
||||
button { font-family: inherit; }
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// In dev, the frontend runs on Vite's port and proxies the API
|
||||
// (and /auth/*, /api/webhooks/*) to the FastAPI process.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
'/auth': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user