§22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)

The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 00:27:49 -07:00
parent c9fd1c535e
commit 93cf506059
6 changed files with 475 additions and 11 deletions
@@ -0,0 +1,105 @@
// CreateCollectionModal.jsx — §22 S2 endpoint, §22 S4 UI. The create-collection
// form the project directory's "Create your first collection" / "New
// collection" control opens. S2 shipped the POST
// /api/projects/:id/collections endpoint but left it UI-less; S4 surfaces it,
// gated on the viewer's `can_create_collection` capability.
//
// The form lets the Owner choose an id (a slug, not "default"), a type, an
// optional display name, and an optional visibility (defaulting to the
// project's). The backend commits a `.collection.yaml`, re-mirrors the
// registry, and returns the new collection; on success the caller refreshes
// the directory.
import { useState } from 'react'
import { createCollection } from '../api'
const TYPE_OPTIONS = [
{ value: 'document', label: 'Document — prose RFCs' },
{ value: 'specification', label: 'Specification' },
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
]
const VISIBILITY_OPTIONS = [
{ value: '', label: 'Inherit from project' },
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted (link-only)' },
{ value: 'gated', label: 'Gated (hidden from the public)' },
]
export default function CreateCollectionModal({ projectId, onClose, onCreated }) {
const [collectionId, setCollectionId] = useState('')
const [type, setType] = useState('document')
const [name, setName] = useState('')
const [visibility, setVisibility] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
async function handleCreate(e) {
e.preventDefault()
const cid = collectionId.trim().toLowerCase()
if (!cid) return
setSubmitting(true)
setError(null)
try {
const col = await createCollection(projectId, {
collectionId: cid,
type,
name: name.trim() || null,
visibility: visibility || null,
})
onCreated?.(col)
} catch (err) {
setError(err.message || 'Failed to create the collection.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-header">
<h2>New collection</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<form onSubmit={handleCreate} className="invitations-form">
<label htmlFor="col-id">Collection id</label>
<input
id="col-id"
value={collectionId}
onChange={e => setCollectionId(e.target.value)}
placeholder="e.g. model, features"
autoFocus
required
/>
<label htmlFor="col-type" style={{ marginTop: 10 }}>Type</label>
<select id="col-type" value={type} onChange={e => setType(e.target.value)}>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="col-name" style={{ marginTop: 10 }}>Display name (optional)</label>
<input
id="col-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. The Model"
/>
<label htmlFor="col-vis" style={{ marginTop: 10 }}>Visibility</label>
<select id="col-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create collection'}
</button>
{error && <span style={{ color: '#c33' }}>{error}</span>}
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}