§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
@@ -2,23 +2,39 @@
// project's caller-visible collections as cards linking into each collection's
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
// single-collection UX, preserved). The role-keyed "Create your first
// collection" empty state is S4; S2 shows a minimal note when there are none.
// single-collection UX, preserved).
//
// §22 S4 — role-aware empty states + owner controls. The list response carries
// a `viewer` capability block; the directory reads it to render:
// * C3.3: a project Owner sees a "Create your first collection" CTA (and a
// "New collection" control when the directory is non-empty), opening the
// create-collection modal (choose a type + id + visibility).
// * C3.4: a contributor without create rights sees the empty directory with
// no create action.
// * C.2: an Owner with membership-management reach sees a "Members" control
// opening the scope-role invitation modal.
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { listCollections } from '../api'
import { collectionHome } from '../lib/entryPaths'
import { entryNoun } from './ProjectLayout.jsx'
import CreateCollectionModal from './CreateCollectionModal.jsx'
import ScopeMembersModal from './ScopeMembersModal.jsx'
export default function CollectionDirectory({ projectId }) {
const [cols, setCols] = useState(null)
const [viewer, setViewer] = useState(null)
const [version, setVersion] = useState(0)
const [createOpen, setCreateOpen] = useState(false)
const [membersOpen, setMembersOpen] = useState(false)
useEffect(() => {
let live = true
listCollections(projectId)
.then(d => { if (live) setCols(d.items) })
.catch(() => { if (live) setCols([]) })
.then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } })
.catch(() => { if (live) { setCols([]); setViewer(null) } })
return () => { live = false }
}, [projectId])
}, [projectId, version])
if (cols === null) {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
@@ -27,12 +43,36 @@ export default function CollectionDirectory({ projectId }) {
if (cols.length === 1) {
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
}
const canCreate = !!viewer?.can_create_collection
const canInvite = !!viewer?.can_invite
return (
<main className="chrome-pane">
<div className="directory">
<h1>Collections</h1>
<div className="directory-head">
<h1>Collections</h1>
<div className="directory-actions">
{canInvite && (
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
)}
{canCreate && cols.length > 0 && (
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
)}
</div>
</div>
{cols.length === 0 ? (
<p className="directory-tagline">No collections yet.</p>
// C3.3 / C3.4: role-keyed empty state.
canCreate ? (
<div className="directory-empty">
<p className="directory-tagline">No collections yet.</p>
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
Create your first collection
</button>
</div>
) : (
<p className="directory-tagline">No collections yet.</p>
)
) : (
<ul className="directory-list">
{cols.map(c => (
@@ -46,6 +86,21 @@ export default function CollectionDirectory({ projectId }) {
</ul>
)}
</div>
{createOpen && (
<CreateCollectionModal
projectId={projectId}
onClose={() => setCreateOpen(false)}
onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }}
/>
)}
{membersOpen && (
<ScopeMembersModal
projectId={projectId}
collections={cols}
viewer={viewer}
onClose={() => setMembersOpen(false)}
/>
)}
</main>
)
}