Members
Part of @cobuntu/embed-react: headless, data via props, links through your router.
The directory
"use client";
import { useState } from "react";
import Link from "next/link";
import { MemberDirectory, type RenderLink } from "@cobuntu/embed-react";
import type { DirectoryResult } from "@cobuntu/embed";
const renderLink: RenderLink = ({ href, className, children, ...rest }) => (
<Link href={href} className={className} {...rest}>{children}</Link>
);
export function Directory({ initial }: { initial: DirectoryResult }) {
const [members, setMembers] = useState(initial.members);
const [cursor, setCursor] = useState(initial.nextCursor);
const [busy, setBusy] = useState(false);
async function loadMore() {
setBusy(true);
const r = await fetch(`/api/community/directory?cursor=${cursor}`); // your own route
const d = await r.json();
setMembers((m) => [...m, ...d.members]);
setCursor(d.nextCursor);
setBusy(false);
}
return (
<MemberDirectory
members={members}
hasMore={!!cursor}
onLoadMore={loadMore}
loadingMore={busy}
profileHref={(m) => `/community/${m.usertag}`}
renderLink={renderLink}
/>
);
}What it does, out of the box:
- Sorted by name, accent-aware (
localeCompare, base sensitivity). - Search across the whole profile (case- and accent-insensitive, every term must match): name, @handle, signature, bio, role, tier, location, languages and notes. Every word must match somewhere, so "marketing portuguese" finds someone who does marketing and speaks Portuguese.
- Facet filters behind a Filters button: type, tier, place, languages, notes. OR within a facet ("Porto or Lisbon"), AND across facets. A facet is only offered when it has more than one option. Every option shows how many people it would leave.
- The contact peek: tapping a card opens a dialog with the bio, notes, links and details, and a Profile link. Escape, the backdrop and Close dismiss it.
- Load more while
hasMoreis true.
MemberDirectory props
| Prop | Type | Notes |
|---|---|---|
members | CbMember[] | Required. DirectoryResult.members fits as is. |
facets | CbDirectoryFacets | DirectoryResult.facets. When given, the sheet offers these (whole roster); otherwise facets are built from members. |
search / defaultSearch / onSearchChange | string / string / (q) => void | Controlled or uncontrolled search. |
filters / defaultFilters / onFiltersChange | FilterValue | Controlled or uncontrolled filters ({ tiers: ["Mentor"], locations: [...] }). |
filterMode | "client" | "server" | Default "client": narrows the loaded members in the browser. "server": shows members as given, so refetch in the callbacks. |
totalCount | number | Summary count in server mode. |
hasMore / onLoadMore / loadingMore / loadMoreError | Pagination. | |
profileHref | (m) => string | Default /members/<usertag>. |
renderLink | RenderLink | Default: a plain <a>. |
peek | boolean | Card tap opens the contact peek. Default true. |
viewerId | string | Marks the viewer's own card "You". |
locale | string | For dates. |
labels | Partial<CbLabels> | Replace any string. |
renderCardActions, renderPeekActions, presenceOf, renderNotes | Platform slots. Empty by default. | |
renderLinkIcon | (network, url) => ReactNode | An icon inside each link pill. |
The profile
import { MemberProfile } from "@cobuntu/embed-react";
<MemberProfile
member={member} // MemberProfile from cobuntu.me.member(handle)
renderLink={renderLink}
eventHref={(e) => `/events/${e.slug}`}
/>The avatar sits beside the name, handle, pronouns, signature, bio and link pills; the action row runs beneath at full width; then the tabs. The Events tab lists the events the member hosts, or an empty state.
| Prop | Type | Notes |
|---|---|---|
member | CbMemberProfile | Required. |
renderLink | RenderLink | For the event links. |
eventHref | (event) => string | Default /events/<slug>. |
formatEventDate | (iso) => string | Default: day, month, year in locale. |
onShare | () => void | Shows a Share button. |
eventsTabIcon | ReactNode | Icon in the Events tab and its empty state. |
renderActions, renderStats, renderAvatar, presence, extraTabs | Platform slots. | |
labels, locale, renderLinkIcon | As above. |
The gate
When the API answers 401 (signed out) or 403 MEMBERS_ONLY (signed in, not a
member), render the gate instead:
<MembersGate reason="signed-out" signInHref="/login?next=/community" joinHref="/join" renderLink={renderLink} />
<MembersGate reason="not-member" joinHref="/join" renderLink={renderLink} />| Prop | Notes |
|---|---|
reason | "signed-out" offers sign in and join; "not-member" offers join. |
signInHref, joinHref | Rendered through renderLink. |
signInSlot, joinSlot | Replace an action entirely (a button that opens your modal). |
as | Heading level, default h1. |
Smaller pieces
MemberCard, MemberFilterBar and MemberContactPeek are exported for custom
layouts. The pure helpers behind the directory are too: filterMembersBySearch,
buildMemberFacets, facetsFromServer, memberPassesFilters, toMemberFilters,
sortMembersByName, normalizeProfileLinks, getSocialNetwork.
Class map
See the class map conventions.
Directory
| Class | Element | State attributes |
|---|---|---|
cb-directory | Root | data-state="ready | no-results | empty", data-filter-mode |
cb-directory__notes | Notes slot wrapper | |
cb-directory__grid / __item | <ul> of cards / each <li> | |
cb-directory__more / __load-more / __error | Load more row, button, error | data-state="idle | loading" |
cb-empty / __title / __hint / __icon | Empty states | data-kind="members | search | events" |
Filter bar
| Class | Element | State attributes |
|---|---|---|
cb-filter | Root | data-active |
cb-filter__row | Toolbar row | |
cb-filter__search / __search-icon / __search-input / __search-clear | Search field | data-filled |
cb-filter__toggle / __toggle-label / __badge | Filters button, its word, the count | data-active, aria-expanded |
cb-filter__clear | Clear beside the toolbar | |
cb-filter__summary / __count | "12 people · Lisbon" line | |
cb-filter__backdrop / __sheet / __handle | Sheet overlay, dialog, drag handle | |
cb-filter__sheet-header / __sheet-title / __close | Sheet header | |
cb-filter__body / __facet / __facet-title / __options | Facet groups | data-facet, data-single |
cb-filter__option / __check / __option-label / __option-count | One option | data-state="on | off", aria-pressed; check: data-shape="radio | checkbox" |
cb-filter__footer / __reset / __done | Clear and "Show N" | disabled |
Card
| Class | Element | State attributes |
|---|---|---|
cb-member-card | Root | data-interactive, data-own |
cb-member-card__identity / __text | Avatar row / name column | |
cb-member-card__avatar (+ cb-avatar) | Avatar | data-has-image |
cb-member-card__name / __name-text | Name line | |
cb-member-card__handle / __subline | @handle / place · languages | |
cb-member-card__pills | Pill row | |
cb-member-card__signature | Headline (clamp it to 3 lines) | |
cb-member-card__spacer | Grows so actions sit on the floor | |
cb-member-card__actions / __profile | Actions row / Profile link |
Peek
| Class | Element | State attributes |
|---|---|---|
cb-peek / __backdrop / __frame / __surface | Root, overlay, positioning frame, dialog | data-state="open" |
cb-peek__header / __close-x / __eyebrow | Header, X (show it on phones), "Contact" | |
cb-peek__body / __identity / __avatar / __name / __handle | Identity | |
cb-peek__sections / __section / __section-label | Section blocks | data-section="about | notes | links | details" |
cb-peek__signature / __bio | About | |
cb-peek__notes / __note | Notes | data-category |
cb-peek__details / __detail / __detail-label / __detail-value | Details rows | |
cb-peek__actions / __action / __profile | Quick actions | |
cb-peek__close | Footer Close (show it on desktop) |
Profile
| Class | Element | State attributes |
|---|---|---|
cb-profile | Root | data-has-notes |
cb-profile__header / __media / __avatar / __info | Two columns | |
cb-profile__identity / __name / __handle / __pronouns | Identity | |
cb-profile__stats | Stats slot | |
cb-profile__about / __signature / __bio | About | |
cb-profile__links | Link pills list | |
cb-profile__actions / __share | Action row | |
cb-profile__tabs, cb-tabs / __tab / __icon / __label / __panel | Tabs | data-state="active | inactive", aria-selected; panel: data-tab |
cb-profile__events, cb-event / __link / __banner / __img / __name / __date | Hosted events | banner: data-has-image |
Gate
| Class | Element | State attributes |
|---|---|---|
cb-gate / __title / __body / __actions / __sign-in / __join | The gate | data-reason="signed-out | not-member" |