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:
Ben Stull
2026-05-24 23:40:49 -07:00
parent f67d0aa0db
commit 060fa408a2
14 changed files with 2722 additions and 158 deletions
+408
View File
@@ -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)
}
+51 -15
View File
@@ -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>
)
}
+61
View File
@@ -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>
)
}