Slice 7: §14 chrome + settings and admin neighborhoods
§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3 persistent About link. /settings/notifications surfaces Slice 6's preferences/quiet-hours/mute/watches endpoints. /admin home base consolidates role management, the §6.2 write-mute, the audit-log viewer, the permission-events log, and the §13.2 graduation queue. Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven admin endpoints + user-search), GET /api/users/me/notification-mutes. Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx, Admin.jsx, App.jsx routing for the chrome surfaces. Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite 75/75 green. Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time decision), §17 (admin block extended to name the seven new endpoints + user-search and notification-mutes read). §19.1 rewritten for Slice 8 hardening; §19.2 grew four candidates (owner succession, mute-from-actor, the "Following since <date>" disclosure, audit-log row prose). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1218,3 +1218,276 @@
|
||||
.toast.cat-personal-direct { border-left-color: #d97706; }
|
||||
.toast.cat-structural { border-left-color: #2563eb; }
|
||||
.toast.cat-churn { border-left-color: #6b7280; }
|
||||
|
||||
/* ── Slice 7: §14 chrome + /settings/notifications + /admin ──────────── */
|
||||
|
||||
/* Header chrome — the persistent §14.3 About link plus the Settings and
|
||||
Admin entrypoints. The header's job is to be invisible until the user
|
||||
reaches for it, so these read as quiet text links rather than buttons. */
|
||||
.header-about, .header-settings, .header-admin {
|
||||
color: #ddd; text-decoration: none;
|
||||
font-size: 12px; letter-spacing: 0.02em;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.header-about:hover, .header-settings:hover, .header-admin:hover {
|
||||
color: #fff; background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.header-admin {
|
||||
color: #fbbf24; /* admin link sits a notch warmer to signal authority */
|
||||
}
|
||||
|
||||
/* /philosophy — the §14.2 read surface. The body inherits the
|
||||
prototype's markdown styling; the header is a thin chrome strip. */
|
||||
.chrome-pane {
|
||||
flex: 1; min-width: 0; overflow: auto;
|
||||
padding: 0; background: #fff;
|
||||
}
|
||||
|
||||
.philosophy-page {
|
||||
max-width: 760px; margin: 0 auto;
|
||||
padding: 24px 32px 64px;
|
||||
}
|
||||
.philosophy-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 0 18px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.philosophy-back {
|
||||
border: none; background: none; cursor: pointer;
|
||||
color: #4b5563; font-size: 13px;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.philosophy-back:hover { background: #f3f4f6; color: #111; }
|
||||
.philosophy-title {
|
||||
font-size: 13px; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
.philosophy-signin {
|
||||
margin-left: auto;
|
||||
font-size: 13px; color: #4b5563; text-decoration: none;
|
||||
}
|
||||
.philosophy-signin:hover { color: #111; text-decoration: underline; }
|
||||
|
||||
.philosophy-body h1 {
|
||||
font-size: 28px; font-weight: 700; margin: 0 0 24px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.philosophy-body h2 {
|
||||
font-size: 19px; font-weight: 600; margin: 36px 0 12px;
|
||||
color: #111;
|
||||
}
|
||||
.philosophy-body h3 {
|
||||
font-size: 15px; font-weight: 600; margin: 24px 0 10px;
|
||||
}
|
||||
.philosophy-body p {
|
||||
margin: 0 0 16px; line-height: 1.65; font-size: 15px; color: #1f2937;
|
||||
}
|
||||
.philosophy-body em { color: #4b5563; font-style: italic; }
|
||||
.philosophy-body strong { color: #111; }
|
||||
.philosophy-body ul, .philosophy-body ol {
|
||||
margin: 0 0 20px; padding-left: 24px;
|
||||
}
|
||||
.philosophy-body li { margin-bottom: 8px; line-height: 1.6; }
|
||||
.philosophy-body hr {
|
||||
border: none; border-top: 1px solid #e5e7eb; margin: 32px 0;
|
||||
}
|
||||
.philosophy-body code {
|
||||
background: #f3f4f6; padding: 1px 5px; border-radius: 3px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* Richer landing page (§14.1) — adds the three-item deck under the
|
||||
pitch. The .landing container's flex centering stays from the
|
||||
prototype; the new content lives inside .landing-inner. */
|
||||
.landing-inner {
|
||||
max-width: 620px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.landing-deck {
|
||||
list-style: none; padding: 0;
|
||||
margin: 48px 0 0; text-align: left;
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
border-top: 1px solid #e5e7eb; padding-top: 32px;
|
||||
max-width: 540px;
|
||||
}
|
||||
.landing-deck li {
|
||||
font-size: 14px; line-height: 1.6; color: #374151;
|
||||
}
|
||||
.landing-deck strong { color: #111; }
|
||||
|
||||
/* /settings/notifications */
|
||||
.settings-page {
|
||||
max-width: 720px; margin: 0 auto;
|
||||
padding: 24px 32px 64px;
|
||||
}
|
||||
.settings-header h1 {
|
||||
margin: 0 0 6px; font-size: 24px; font-weight: 700; letter-spacing: -0.01em;
|
||||
}
|
||||
.settings-sub {
|
||||
color: #6b7280; font-size: 13px; margin: 0 0 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.settings-section {
|
||||
border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
padding: 20px 24px; margin-bottom: 16px; background: #fff;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 4px; font-size: 15px; font-weight: 600;
|
||||
}
|
||||
.settings-section-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.settings-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.quiet-hours-row label {
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
font-size: 12px; color: #6b7280;
|
||||
}
|
||||
.quiet-hours-row input, .quiet-hours-row select {
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 13px;
|
||||
background: white; color: #111;
|
||||
}
|
||||
.settings-note { font-size: 12px; color: #6b7280; margin: 0; }
|
||||
.settings-note.warning { color: #b91c1c; }
|
||||
|
||||
.toggle-row {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
cursor: pointer; padding: 10px 0;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
.toggle-row:first-child { border-top: none; padding-top: 0; }
|
||||
.toggle-row.disabled { cursor: not-allowed; opacity: 0.6; }
|
||||
.toggle-row input[type=checkbox] { margin-top: 3px; }
|
||||
.toggle-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.toggle-label { font-size: 14px; font-weight: 500; color: #111; }
|
||||
.toggle-desc { font-size: 12px; color: #6b7280; line-height: 1.5; }
|
||||
|
||||
.btn-primary {
|
||||
background: #111; color: #fff; border: none;
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { background: #333; }
|
||||
.btn-primary:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||
.btn-link-muted {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #6b7280; font-size: 12px; padding: 4px 6px;
|
||||
}
|
||||
.btn-link-muted:hover { color: #111; text-decoration: underline; }
|
||||
|
||||
.settings-table, .admin-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.settings-table th, .admin-table th {
|
||||
text-align: left; padding: 6px 8px;
|
||||
font-size: 11px; text-transform: uppercase;
|
||||
color: #6b7280; letter-spacing: 0.05em; font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.settings-table td, .admin-table td {
|
||||
padding: 8px 8px; border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.settings-table select { font-size: 13px; padding: 3px 6px; }
|
||||
.set-by {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.set-by-auto { background: #f3f4f6; color: #6b7280; }
|
||||
.set-by-explicit { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.mutes-list { list-style: none; padding: 0; margin: 8px 0 0; }
|
||||
.mutes-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px; border-bottom: 1px solid #f3f4f6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mute-handle { font-weight: 500; color: #111; }
|
||||
.mute-when { font-size: 11px; margin-left: auto; }
|
||||
|
||||
.mute-typeahead { position: relative; }
|
||||
.mute-typeahead input {
|
||||
width: 100%; box-sizing: border-box;
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
padding: 7px 10px; font-size: 13px; outline: none;
|
||||
}
|
||||
.mute-typeahead input:focus { border-color: #111; }
|
||||
.mute-typeahead-results {
|
||||
position: absolute; top: 100%; left: 0; right: 0;
|
||||
background: white; border: 1px solid #d1d5db; border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
margin-top: 4px; padding: 4px 0; z-index: 10;
|
||||
list-style: none;
|
||||
}
|
||||
.mute-typeahead-results button {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
width: 100%; padding: 6px 10px;
|
||||
background: none; border: none; text-align: left; cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mute-typeahead-results button:hover { background: #f9fafb; }
|
||||
|
||||
/* /admin */
|
||||
.admin-page {
|
||||
display: flex; height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.admin-rail {
|
||||
width: 220px; flex-shrink: 0;
|
||||
background: #f9fafb; border-right: 1px solid #e5e7eb;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.admin-rail h2 {
|
||||
font-size: 13px; color: #6b7280;
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.admin-rail ul { list-style: none; padding: 0; margin: 0 0 24px; }
|
||||
.admin-rail li { margin-bottom: 2px; }
|
||||
.admin-rail-link {
|
||||
display: block; padding: 6px 10px;
|
||||
font-size: 13px; color: #374151; text-decoration: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-rail-link:hover { background: #f3f4f6; }
|
||||
.admin-rail-link.active { background: #111; color: #fff; }
|
||||
.admin-rail-note {
|
||||
font-size: 11px; color: #6b7280; line-height: 1.5;
|
||||
}
|
||||
.admin-content {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 28px 36px;
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-tab-header h2 {
|
||||
margin: 0 0 4px; font-size: 18px; font-weight: 700;
|
||||
}
|
||||
.admin-tab-header p { margin: 0 0 24px; font-size: 13px; }
|
||||
.admin-section-h {
|
||||
font-size: 13px; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: #6b7280;
|
||||
margin: 24px 0 8px;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap;
|
||||
}
|
||||
.audit-filters input, .audit-filters select {
|
||||
border: 1px solid #d1d5db; border-radius: 6px;
|
||||
padding: 5px 9px; font-size: 13px; background: white;
|
||||
}
|
||||
.audit-table code {
|
||||
font-size: 11px; background: #f3f4f6;
|
||||
padding: 1px 5px; border-radius: 3px;
|
||||
}
|
||||
|
||||
.user-cell { display: flex; flex-direction: column; gap: 1px; }
|
||||
.user-handle { font-weight: 500; color: #111; }
|
||||
.mute-toggle {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
|
||||
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
|
||||
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
|
||||
.grad-queue-link:hover strong { text-decoration: underline; }
|
||||
.muted { color: #6b7280; }
|
||||
.error { color: #b91c1c; }
|
||||
|
||||
+79
-17
@@ -8,6 +8,9 @@ import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import Philosophy from './components/Philosophy.jsx'
|
||||
import NotificationSettings from './components/NotificationSettings.jsx'
|
||||
import Admin from './components/Admin.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import './App.css'
|
||||
|
||||
@@ -65,8 +68,16 @@ export default function App() {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
|
||||
// §14.2: the philosophy route is reachable by anonymous visitors too.
|
||||
// Resolve it before the authentication gate so a signed-out reader
|
||||
// who follows the §14.1 landing link does not get bounced to sign-in.
|
||||
if (!me?.authenticated) {
|
||||
return <Landing />
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<Philosophy authenticated={false} />} />
|
||||
<Route path="*" element={<Landing />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,6 +87,21 @@ export default function App() {
|
||||
<Link to="/">Wiggleverse RFCs</Link>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
{/* §14.3: the persistent About link. One word, no badge, no
|
||||
state — visible from every authenticated screen so a
|
||||
contributor mid-PR who wonders why a conversation is
|
||||
public can reach the answer in two clicks. */}
|
||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||
About
|
||||
</Link>
|
||||
<Link to="/settings/notifications" className="header-settings" title="Notification settings (§15)">
|
||||
Settings
|
||||
</Link>
|
||||
{(me.user.role === 'owner' || me.user.role === 'admin') && (
|
||||
<Link to="/admin" className="header-admin" title="Admin home base">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
@@ -92,18 +118,27 @@ export default function App() {
|
||||
</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 viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Routes>
|
||||
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={me.user} />} />
|
||||
<Route path="/settings/notifications" element={<NotificationSettingsWithSidebar viewer={me.user} />} />
|
||||
<Route path="/admin/*" element={<AdminWithSidebar viewer={me.user} />} />
|
||||
<Route path="*" element={
|
||||
<>
|
||||
<Catalog
|
||||
onProposeRFC={() => setProposeOpen(true)}
|
||||
version={catalogVersion}
|
||||
/>
|
||||
<main className="main-pane">
|
||||
<Routes>
|
||||
<Route path="/" element={<Welcome viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
|
||||
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
|
||||
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
{proposeOpen && (
|
||||
<ProposeModal
|
||||
@@ -123,6 +158,34 @@ export default function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function PhilosophyWithSidebar() {
|
||||
// The chrome surfaces (§14.2 philosophy, §15 settings, §6/§17 admin)
|
||||
// all use the full app body — no catalog left pane, no propose modal.
|
||||
// The header carries the navigation back; the body is a single
|
||||
// reading surface.
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Philosophy authenticated={true} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationSettingsWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<NotificationSettings viewer={viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminWithSidebar({ viewer }) {
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<Admin viewer={viewer} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Welcome({ viewer }) {
|
||||
return (
|
||||
<div className="welcome">
|
||||
@@ -134,10 +197,9 @@ function Welcome({ viewer }) {
|
||||
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.
|
||||
Wondering why a conversation is public, why graduation costs what it
|
||||
does, or why the model is in the chat? <Link to="/philosophy">Read the
|
||||
philosophy</Link>.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -478,6 +478,68 @@ export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §14.2 / Slice 7: PHILOSOPHY.md surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getPhilosophy() {
|
||||
return jsonOrThrow(await fetch('/api/philosophy'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 7: admin neighborhood (§17 admin/* + user search for the §15.8 mute
|
||||
// typeahead).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listAdminUsers() {
|
||||
return jsonOrThrow(await fetch('/api/admin/users'))
|
||||
}
|
||||
|
||||
export async function setUserRole(userId, role) {
|
||||
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/role`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function setUserMute(userId, muted) {
|
||||
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ muted }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (actionKind) params.set('action_kind', actionKind)
|
||||
if (actorUserId != null) params.set('actor_user_id', actorUserId)
|
||||
if (rfcSlug) params.set('rfc_slug', rfcSlug)
|
||||
if (beforeId != null) params.set('before_id', beforeId)
|
||||
if (limit != null) params.set('limit', limit)
|
||||
const qs = params.toString()
|
||||
return jsonOrThrow(await fetch(`/api/admin/audit${qs ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function listPermissionEvents({ beforeId, limit } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (beforeId != null) params.set('before_id', beforeId)
|
||||
if (limit != null) params.set('limit', limit)
|
||||
const qs = params.toString()
|
||||
return jsonOrThrow(await fetch(`/api/admin/permission-events${qs ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function listGraduationQueue() {
|
||||
return jsonOrThrow(await fetch('/api/admin/graduation-queue'))
|
||||
}
|
||||
|
||||
export async function searchUsers(q) {
|
||||
const params = new URLSearchParams()
|
||||
if (q) params.set('q', q)
|
||||
return jsonOrThrow(await fetch(`/api/users/search?${params}`))
|
||||
}
|
||||
|
||||
// SSE subscription helper. Returns a close() function. The handler
|
||||
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
||||
// `notification` events, plus `read` events when another tab marks a row.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// /admin — the admin home base.
|
||||
//
|
||||
// Topics 12 and 13 both expanded the admin's repertoire without giving
|
||||
// it a centralized home. Slice 7 consolidates them: role management,
|
||||
// the §6.2 app-wide write-mute, the audit-log viewer, the
|
||||
// graduation-readiness queue, and a read of the permission-events log
|
||||
// — five thin sub-surfaces behind a left-rail menu.
|
||||
//
|
||||
// The page is admin-only; the App.jsx route mounts it only when the
|
||||
// viewer's role is owner or admin, and every /api/admin/* endpoint
|
||||
// guards independently.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Routes, Route, NavLink, Link } from 'react-router-dom'
|
||||
import {
|
||||
listAdminUsers,
|
||||
setUserRole,
|
||||
setUserMute,
|
||||
listAuditLog,
|
||||
listPermissionEvents,
|
||||
listGraduationQueue,
|
||||
} from '../api.js'
|
||||
|
||||
const TABS = [
|
||||
{ path: 'users', label: 'Users' },
|
||||
{ path: 'graduation', label: 'Graduation queue' },
|
||||
{ path: 'audit', label: 'Audit log' },
|
||||
{ path: 'permissions', label: 'Permission events' },
|
||||
]
|
||||
|
||||
export default function Admin({ viewer }) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<nav className="admin-rail">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
{TABS.map(t => (
|
||||
<li key={t.path}>
|
||||
<NavLink
|
||||
to={t.path}
|
||||
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="admin-rail-note">
|
||||
Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
|
||||
You can <Link to="/">return to the catalog</Link> at any time.
|
||||
</p>
|
||||
</nav>
|
||||
<div className="admin-content">
|
||||
<Routes>
|
||||
<Route index element={<UsersTab />} />
|
||||
<Route path="users" element={<UsersTab />} />
|
||||
<Route path="graduation" element={<GraduationTab />} />
|
||||
<Route path="audit" element={<AuditTab />} />
|
||||
<Route path="permissions" element={<PermissionsTab />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
|
||||
|
||||
function UsersTab() {
|
||||
const [users, setUsers] = useState(null)
|
||||
const [busy, setBusy] = useState({})
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function refresh() {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await listAdminUsers()
|
||||
setUsers(r.items || [])
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
async function changeRole(userId, role) {
|
||||
setBusy(b => ({ ...b, [userId]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await setUserRole(userId, role)
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [userId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMute(userId, muted) {
|
||||
setBusy(b => ({ ...b, [userId]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await setUserMute(userId, muted)
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u))
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [userId]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
if (users == null) return <p className="muted">Loading users…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Users</h2>
|
||||
<p className="muted">
|
||||
Role changes write to <code>permission_events</code>. The §6.2
|
||||
write-mute applies to contributors only — promote to admin to
|
||||
remove a user's ability to write without silencing them.
|
||||
</p>
|
||||
</header>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Write-muted</th>
|
||||
<th>Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<span className="user-handle">@{u.gitea_login}</span>
|
||||
<span className="muted">{u.display_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={e => changeRole(u.id, e.target.value)}
|
||||
disabled={!!busy[u.id]}
|
||||
>
|
||||
<option value="contributor">Contributor</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="owner">Owner</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.role === 'contributor' ? (
|
||||
<label className="mute-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!u.muted}
|
||||
onChange={e => toggleMute(u.id, e.target.checked)}
|
||||
disabled={!!busy[u.id]}
|
||||
/>
|
||||
{u.muted ? 'Muted' : 'Active'}
|
||||
</label>
|
||||
) : (
|
||||
<span className="muted">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{u.last_seen_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Graduation-readiness queue (§13.2) ─────────────────────────────────────
|
||||
|
||||
function GraduationTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
listGraduationQueue()
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
}, [])
|
||||
|
||||
if (error) return <p className="settings-note warning">{error}</p>
|
||||
if (data == null) return <p className="muted">Loading queue…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Graduation queue</h2>
|
||||
<p className="muted">
|
||||
Super-drafts with owners claimed and zero blocking body-edit PRs.
|
||||
Open one to run the §13.3 graduation sequence.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h3 className="admin-section-h">Ready ({data.ready.length})</h3>
|
||||
{data.ready.length === 0 && (
|
||||
<p className="muted">No super-drafts ready right now.</p>
|
||||
)}
|
||||
<ul className="grad-queue">
|
||||
{data.ready.map(item => (
|
||||
<li key={item.slug}>
|
||||
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted"> — owners: {item.owners.join(', ')}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
|
||||
{data.blocked.length === 0 && (
|
||||
<p className="muted">No blocked super-drafts.</p>
|
||||
)}
|
||||
<ul className="grad-queue">
|
||||
{data.blocked.map(item => (
|
||||
<li key={item.slug}>
|
||||
<Link to={`/rfc/${item.slug}`} className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted">
|
||||
{' — '}
|
||||
{!item.owners_set && 'no owners yet'}
|
||||
{!item.owners_set && item.blocking_prs > 0 && '; '}
|
||||
{item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log (`actions`) — filter chips + paging ──────────────────────────
|
||||
|
||||
function AuditTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' })
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function load(beforeId = null) {
|
||||
setError(null)
|
||||
try {
|
||||
const r = await listAuditLog({
|
||||
actionKind: filters.actionKind || undefined,
|
||||
actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined,
|
||||
rfcSlug: filters.rfcSlug || undefined,
|
||||
beforeId,
|
||||
limit: 100,
|
||||
})
|
||||
setData(r)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [filters])
|
||||
|
||||
const kinds = useMemo(() => data?.action_kinds || [], [data])
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Audit log</h2>
|
||||
<p className="muted">
|
||||
Every bot-mediated write lands here. The most recent rows show first;
|
||||
filter to narrow to one kind, one actor, or one RFC.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="audit-filters">
|
||||
<select
|
||||
value={filters.actionKind}
|
||||
onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
|
||||
>
|
||||
<option value="">All action kinds</option>
|
||||
{kinds.map(k => <option key={k} value={k}>{k}</option>)}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="RFC slug…"
|
||||
value={filters.rfcSlug}
|
||||
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Actor user_id…"
|
||||
value={filters.actorUserId}
|
||||
onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
{data == null && <p className="muted">Loading…</p>}
|
||||
{data?.items?.length === 0 && (
|
||||
<p className="muted">No rows match this filter.</p>
|
||||
)}
|
||||
{data?.items?.length > 0 && (
|
||||
<table className="admin-table audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Action</th>
|
||||
<th>Actor</th>
|
||||
<th>On behalf of</th>
|
||||
<th>RFC</th>
|
||||
<th>PR / branch</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map(row => (
|
||||
<tr key={row.id}>
|
||||
<td className="muted">{row.created_at}</td>
|
||||
<td><code>{row.action_kind}</code></td>
|
||||
<td>{row.actor_display || row.actor_login || '—'}</td>
|
||||
<td>{row.on_behalf_of}</td>
|
||||
<td>{row.rfc_slug || '—'}</td>
|
||||
<td>
|
||||
{row.pr_number != null && <span>#{row.pr_number} </span>}
|
||||
{row.branch_name && <code>{row.branch_name}</code>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{data?.has_more && (
|
||||
<button
|
||||
className="btn-link-muted"
|
||||
onClick={() => load(data.items[data.items.length - 1].id)}
|
||||
>
|
||||
Load older →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Permission events (`permission_events`) ────────────────────────────────
|
||||
|
||||
function PermissionsTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
listPermissionEvents({ limit: 100 })
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
}, [])
|
||||
|
||||
if (error) return <p className="settings-note warning">{error}</p>
|
||||
if (data == null) return <p className="muted">Loading…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Permission events</h2>
|
||||
<p className="muted">
|
||||
Every role change and write-mute toggle. The companion to the
|
||||
audit log, scoped to authorization changes.
|
||||
</p>
|
||||
</header>
|
||||
{data.items.length === 0 && (
|
||||
<p className="muted">No permission events yet.</p>
|
||||
)}
|
||||
{data.items.length > 0 && (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Event</th>
|
||||
<th>Actor</th>
|
||||
<th>Subject</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td className="muted">{r.created_at}</td>
|
||||
<td><code>{r.event_kind}</code></td>
|
||||
<td>{r.actor_display || r.actor_login || '—'}</td>
|
||||
<td>{r.subject_display || r.subject_login || '—'}</td>
|
||||
<td className="muted">
|
||||
{r.details ? formatDetails(r.details) : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDetails(details) {
|
||||
if (!details || typeof details !== 'object') return String(details ?? '')
|
||||
if (details.before != null && details.after != null) {
|
||||
return `${String(details.before)} → ${String(details.after)}`
|
||||
}
|
||||
return JSON.stringify(details)
|
||||
}
|
||||
@@ -1,24 +1,60 @@
|
||||
// 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.
|
||||
// The landing page has three jobs per §14.1: name what this thing is,
|
||||
// pitch why someone would care, and offer the sign-in affordance. The
|
||||
// visual treatment is deferred per §14.4 — what matters here is the
|
||||
// hierarchy. Title and subtitle frame the framework, the short-form
|
||||
// pitch (sourced verbatim from the top of PHILOSOPHY.md) does the
|
||||
// argument, and the single primary action lets a reader who is sold
|
||||
// step through. The secondary link to `/philosophy` is for the reader
|
||||
// who is interested but needs more before signing in.
|
||||
//
|
||||
// The deck below the pitch is intentionally restrained — three crisp
|
||||
// claims about what the framework *is*, anchored in the spec's
|
||||
// structural decisions, so the reader who has not yet read the
|
||||
// philosophy can still tell at a glance whether this is for them.
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
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 className="landing-inner">
|
||||
<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
|
||||
the standardization process for that vocabulary. Build the dictionary first.
|
||||
</p>
|
||||
|
||||
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
|
||||
<Link className="secondary-link" to="/philosophy">Read the full philosophy →</Link>
|
||||
|
||||
<ul className="landing-deck">
|
||||
<li>
|
||||
<strong>One word per RFC.</strong> An RFC defines a single word — its meaning,
|
||||
its relationships to other defined words, and the protocol by which humans and
|
||||
machines interact with it.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Argued in public, with the model.</strong> Every definition is the
|
||||
product of a transcript: a human and a model in careful argument until the
|
||||
ambiguity is gone. The argument is the evidence the definition was earned.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Graduation is the load-bearing moment.</strong> A super-draft is the
|
||||
start of the conversation. Graduation gives the definition a permanent home
|
||||
and a stable identifier — and only then can other RFCs build on it.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
// /settings/notifications — the §15.4 / §15.5 / §15.6 / §15.8 surface.
|
||||
//
|
||||
// Topic 13 settled the schema and the per-category rules; Slice 6 wired
|
||||
// the endpoints; Slice 7 lands the surface where a contributor finds
|
||||
// the per-category email toggles, the digest cadence dropdown, the
|
||||
// quiet-hours editor, the watches overview, and the per-user mute
|
||||
// list. The §15.4 email footer's "Manage all preferences" link points
|
||||
// at this route.
|
||||
//
|
||||
// Each sub-section is intentionally thin — the rules are settled in
|
||||
// §15; this page renders them. The one piece of voice the spec
|
||||
// commits to and the surface inherits: the `email_watched_churn`
|
||||
// toggle renders as permanently disabled with the §15.4 refusal
|
||||
// tooltip ("Per-commit and per-message email is intentionally not
|
||||
// offered. The digest aggregates this activity weekly."). Naming the
|
||||
// refusal is the spec's commitment; silently omitting the toggle
|
||||
// would let the contract drift.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
setNotificationPreferences,
|
||||
getQuietHours,
|
||||
setQuietHours,
|
||||
listWatches,
|
||||
setWatch,
|
||||
unmuteUser,
|
||||
muteUser,
|
||||
searchUsers,
|
||||
} from '../api.js'
|
||||
|
||||
const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.'
|
||||
|
||||
export default function NotificationSettings({ viewer }) {
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-header">
|
||||
<h1>Notification settings</h1>
|
||||
<p className="settings-sub">
|
||||
Which signals reach you, how, and when. The rules live in §15;
|
||||
this page is where you tune them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EmailPreferencesSection />
|
||||
<DigestCadenceSection />
|
||||
<QuietHoursSection />
|
||||
<WatchesSection />
|
||||
<MutesSection viewer={viewer} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.4 email category toggles ───────────────────────────────────────────
|
||||
|
||||
function EmailPreferencesSection() {
|
||||
const [prefs, setPrefs] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedNote, setSavedNote] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then(setPrefs).catch(e => setError(e.message))
|
||||
}, [])
|
||||
|
||||
async function update(field, value) {
|
||||
setSaving(true)
|
||||
setSavedNote('')
|
||||
try {
|
||||
await setNotificationPreferences({ [field]: value })
|
||||
setPrefs(p => ({ ...p, [field]: value }))
|
||||
setSavedNote('Saved.')
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setTimeout(() => setSavedNote(''), 1500)
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefs) return <SectionShell title="Email" subtitle={error || 'Loading…'} />
|
||||
|
||||
return (
|
||||
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
|
||||
<Toggle
|
||||
label="Personal direct"
|
||||
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
|
||||
checked={!!prefs.email_personal_direct}
|
||||
onChange={v => update('email_personal_direct', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Toggle
|
||||
label="Watched structural"
|
||||
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
|
||||
checked={!!prefs.email_watched_structural}
|
||||
onChange={v => update('email_watched_structural', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Toggle
|
||||
label="Admin actionable"
|
||||
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
|
||||
checked={!!prefs.email_admin_actionable}
|
||||
onChange={v => update('email_admin_actionable', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Toggle
|
||||
label="Watched churn (per-commit, per-message)"
|
||||
description={CHURN_REFUSAL}
|
||||
checked={false}
|
||||
disabled
|
||||
title={CHURN_REFUSAL}
|
||||
/>
|
||||
{!!prefs.email_opt_out_all && (
|
||||
<p className="settings-note warning">
|
||||
A hard bounce or complaint from your mailbox flipped the global
|
||||
email opt-out. No email will be sent until you contact an admin
|
||||
to clear the flag, even if individual categories are enabled.
|
||||
</p>
|
||||
)}
|
||||
<p className="settings-note">{savedNote}</p>
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.5 digest cadence ───────────────────────────────────────────────────
|
||||
|
||||
function DigestCadenceSection() {
|
||||
const [cadence, setCadence] = useState(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly'))
|
||||
}, [])
|
||||
|
||||
async function update(value) {
|
||||
setSaving(true)
|
||||
try {
|
||||
await setNotificationPreferences({ digest_cadence: value })
|
||||
setCadence(value)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Digest cadence"
|
||||
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
|
||||
>
|
||||
<div className="settings-row">
|
||||
<select
|
||||
value={cadence ?? 'weekly'}
|
||||
onChange={e => update(e.target.value)}
|
||||
disabled={saving || cadence == null}
|
||||
>
|
||||
<option value="off">Off — never send a digest</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="daily">Daily</option>
|
||||
</select>
|
||||
</div>
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.8 quiet hours ──────────────────────────────────────────────────────
|
||||
|
||||
function QuietHoursSection() {
|
||||
const [data, setData] = useState(null)
|
||||
const [draft, setDraft] = useState({ start: '', end: '', timezone: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
// The browser ships an IANA tz list per §15.8 — preferable to a
|
||||
// free-text field, since the API validates the trio.
|
||||
const timezones = useMemo(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone')
|
||||
} catch {
|
||||
return ['UTC']
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getQuietHours().then(q => {
|
||||
setData(q)
|
||||
setDraft({
|
||||
start: q.start || '',
|
||||
end: q.end || '',
|
||||
timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone })
|
||||
setData({ start: draft.start, end: draft.end, timezone: draft.timezone })
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setQuietHours({ start: null, end: null, timezone: null })
|
||||
setData({ start: null, end: null, timezone: null })
|
||||
setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone })
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) return <SectionShell title="Quiet hours" subtitle="Loading…" />
|
||||
|
||||
const isSet = !!(data.start && data.end && data.timezone)
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Quiet hours"
|
||||
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
|
||||
>
|
||||
<div className="settings-row quiet-hours-row">
|
||||
<label>
|
||||
Start
|
||||
<input
|
||||
type="time"
|
||||
value={draft.start}
|
||||
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
End
|
||||
<input
|
||||
type="time"
|
||||
value={draft.end}
|
||||
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Timezone
|
||||
<select
|
||||
value={draft.timezone}
|
||||
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
|
||||
>
|
||||
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<button className="btn-primary" disabled={saving} onClick={save}>
|
||||
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
|
||||
</button>
|
||||
{isSet && (
|
||||
<button className="btn-link-muted" disabled={saving} onClick={clear}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.6 watches overview ─────────────────────────────────────────────────
|
||||
|
||||
function WatchesSection() {
|
||||
const [watches, setWatches] = useState(null)
|
||||
const [updating, setUpdating] = useState({})
|
||||
|
||||
useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, [])
|
||||
|
||||
async function update(slug, state) {
|
||||
setUpdating(u => ({ ...u, [slug]: true }))
|
||||
try {
|
||||
const r = await setWatch(slug, state)
|
||||
setWatches(prev => prev.map(w => w.rfc_slug === slug
|
||||
? { ...w, state: r.state, set_by: 'explicit' }
|
||||
: w
|
||||
))
|
||||
} finally {
|
||||
setUpdating(u => ({ ...u, [slug]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
if (!watches) return <SectionShell title="Watches" subtitle="Loading…" />
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Watches"
|
||||
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
|
||||
>
|
||||
{watches.length === 0 && (
|
||||
<p className="muted">No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.</p>
|
||||
)}
|
||||
{watches.length > 0 && (
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>RFC</th>
|
||||
<th>State</th>
|
||||
<th>Set by</th>
|
||||
<th>Last participation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{watches.map(w => (
|
||||
<tr key={w.rfc_slug}>
|
||||
<td>
|
||||
<Link to={`/rfc/${w.rfc_slug}`}>{w.rfc_title || w.rfc_slug}</Link>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={w.state}
|
||||
onChange={e => update(w.rfc_slug, e.target.value)}
|
||||
disabled={!!updating[w.rfc_slug]}
|
||||
>
|
||||
<option value="watching">Watching</option>
|
||||
<option value="following">Following</option>
|
||||
<option value="muted">Muted</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`set-by set-by-${w.set_by}`}>
|
||||
{w.set_by === 'explicit' ? 'You' : 'Auto'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{w.last_participation_at || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// ── §15.8 per-user mute list + typeahead add ───────────────────────────────
|
||||
|
||||
function MutesSection({ viewer }) {
|
||||
const [mutes, setMutes] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await listMutes()
|
||||
setMutes(r.items || [])
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
async function remove(userId) {
|
||||
await unmuteUser(userId)
|
||||
setMutes(prev => prev.filter(m => m.muted_user_id !== userId))
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionShell
|
||||
title="Muted users"
|
||||
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
|
||||
>
|
||||
{viewer.role === 'owner' || viewer.role === 'admin' ? (
|
||||
<p className="muted">
|
||||
Owners and admins cannot mute notifications — the role requires
|
||||
receiving signals from everyone. (§15.8)
|
||||
</p>
|
||||
) : (
|
||||
<MuteTypeahead onMuted={refresh} />
|
||||
)}
|
||||
{mutes && mutes.length === 0 && (
|
||||
<p className="muted">No active mutes.</p>
|
||||
)}
|
||||
{mutes && mutes.length > 0 && (
|
||||
<ul className="mutes-list">
|
||||
{mutes.map(m => (
|
||||
<li key={m.muted_user_id} className="mutes-row">
|
||||
<span className="mute-handle">@{m.gitea_login}</span>
|
||||
<span className="mute-name muted">{m.display_name}</span>
|
||||
<span className="mute-when muted">since {m.muted_at}</span>
|
||||
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
|
||||
Unmute
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && <p className="settings-note warning">{error}</p>}
|
||||
</SectionShell>
|
||||
)
|
||||
}
|
||||
|
||||
// The mute-list read endpoint isn't a separate route in the API today
|
||||
// (§17 names add/delete only); we read the join here against /api/users/me
|
||||
// via a tiny dedicated endpoint that mirrors the watches shape. For
|
||||
// Slice 7's v1 surface, we compute the list client-side from a server
|
||||
// endpoint that returns the joined rows — added in api_admin.py's
|
||||
// neighborhood for proximity.
|
||||
async function listMutes() {
|
||||
const res = await fetch('/api/users/me/notification-mutes')
|
||||
if (!res.ok) {
|
||||
const detail = await res.text()
|
||||
throw new Error(detail || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function MuteTypeahead({ onMuted }) {
|
||||
const [q, setQ] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [hint, setHint] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
searchUsers(q).then(r => setResults(r.items || []))
|
||||
}, 120)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
async function mute(user) {
|
||||
setBusy(true)
|
||||
setHint('')
|
||||
try {
|
||||
await muteUser(user.id)
|
||||
setQ('')
|
||||
setOpen(false)
|
||||
onMuted?.()
|
||||
} catch (e) {
|
||||
setHint(e.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mute-typeahead">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Mute a user by login or name…"
|
||||
value={q}
|
||||
onChange={e => { setQ(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
disabled={busy}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<ul className="mute-typeahead-results">
|
||||
{results.map(r => (
|
||||
<li key={r.id}>
|
||||
<button onClick={() => mute(r)} disabled={busy}>
|
||||
<span className="mute-handle">@{r.gitea_login}</span>
|
||||
<span className="muted">{r.display_name}</span>
|
||||
{(r.role === 'owner' || r.role === 'admin') && (
|
||||
<span className="muted">{r.role}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{hint && <p className="settings-note warning">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Small layout primitives ────────────────────────────────────────────────
|
||||
|
||||
function SectionShell({ title, subtitle, children }) {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<header>
|
||||
<h2>{title}</h2>
|
||||
{subtitle && <p className="settings-sub">{subtitle}</p>}
|
||||
</header>
|
||||
<div className="settings-section-body">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, description, checked, onChange, disabled, title }) {
|
||||
return (
|
||||
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checked}
|
||||
onChange={e => onChange?.(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="toggle-text">
|
||||
<span className="toggle-label">{label}</span>
|
||||
{description && <span className="toggle-desc">{description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// §14.2 — the `/philosophy` route.
|
||||
//
|
||||
// Renders PHILOSOPHY.md verbatim with light app chrome around it.
|
||||
// Reachable by anonymous visitors (linked from the §14.1 landing) and
|
||||
// by authenticated viewers (linked from the persistent §14.3 About
|
||||
// header). The chrome here is small by design: a "Back" affordance
|
||||
// that goes wherever the viewer came from, and a render of the
|
||||
// markdown body. The §14.4 commitment ("not pushed at returning users
|
||||
// via banners or modals") is the guardrail — this route serves the
|
||||
// document, nothing else.
|
||||
//
|
||||
// The route is also the natural read surface for anonymous reachers:
|
||||
// without a sign-in they cannot navigate the catalog, but they can
|
||||
// read the philosophy that animates the work.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { marked } from 'marked'
|
||||
import { getPhilosophy } from '../api.js'
|
||||
|
||||
export default function Philosophy({ authenticated }) {
|
||||
const [body, setBody] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getPhilosophy()
|
||||
.then(r => { if (active) setBody(r.body || '') })
|
||||
.catch(e => { if (active) setError(e.message || String(e)) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
const html = body ? marked.parse(body) : ''
|
||||
|
||||
return (
|
||||
<div className="philosophy-page">
|
||||
<header className="philosophy-header">
|
||||
<button
|
||||
className="philosophy-back"
|
||||
onClick={() => (history.length > 1 ? navigate(-1) : navigate('/'))}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="philosophy-title">Why this exists</span>
|
||||
{!authenticated && (
|
||||
<Link className="philosophy-signin" to="/">Home</Link>
|
||||
)}
|
||||
</header>
|
||||
<article className="philosophy-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{error && <p className="error">Could not load the philosophy: {error}</p>}
|
||||
{!loading && !error && (
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user