Add skate roles to the crew roster (filmer, photographer, etc.)

A skate team is more than "members" — the filmer, photographer and editor are
core functions. Crews can now assign optional, non-pretentious skate roles:

- Types: CrewRole (filmer/photographer/editor/artist/manager/og) + CREW_ROLE_META
  labels/emoji, `crew-role` tag parser, and `crewRoles` on SkateCrew. Roles are
  additive to membership and grant NO powers (only `admin` does); a member can
  hold roles AND be an admin. Only honoured for founder/listed members.
- CollectiveMembers: optional rolesByPubkey → renders 🎥 Filmer / 📷 Photographer
  flex chips next to each member.
- CrewLeadership: leader + captains show their skate-role chips.
- CrewForm: per-member role toggles + a founder ("Your roles") picker; publishes
  `crew-role` tags scoped to the roster.
- CrewDetailPage: builds rolesByPubkey and threads it to members + leadership.
- NIP.md: document the `crew-role` tag + example.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 00:07:30 +07:00
parent 91045e17ae
commit cbd30fee00
6 changed files with 265 additions and 38 deletions

3
NIP.md
View file

@ -2019,6 +2019,8 @@ bare `p` tag on the crew is only a claim until the member self-labels.
["t", "crew-focus-photo"],
["p", "<memberPubkey1>", "", "admin"],
["p", "<memberPubkey2>"],
["crew-role", "<memberPubkey2>", "filmer"],
["crew-role", "<founder>", "og"],
["a", "30818:<spotFounder>:<spotDtag>"],
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
["website", "https://bcnnightcrew.example"],
@ -2062,6 +2064,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
| `coordinator` | Optional **encrypted-chat coordinator** for the crew's private group. Shape: `["coordinator", <url>, <coordinatorPubkey?>, <operatorPubkey?>]`. `<url>` is an `http(s)`/`wss` endpoint of a **Cordn** coordinator (an MLS / RFC 9420 Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content, membership secrets and MLS ratchets live client-side. Nothing crew-specific is published to public relays. `<coordinatorPubkey>` is the coordinator service pubkey (hex) for ContextVM addressing; `<operatorPubkey>` is the human (hex) who runs it, if delegated. |
| `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. |
| `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", <memberPubkey>, <role>]`, repeatable (a member may hold several). `<role>` ∈ `filmer`, `photographer`, `editor`, `artist`, `manager`, `og`. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. |
### Crew-Hosted Events

View file

@ -14,6 +14,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
import { CREW_ROLE_META, type CrewRole } from '@/lib/types';
import { cn } from '@/lib/utils';
interface CollectiveMembersProps {
@ -23,10 +24,22 @@ interface CollectiveMembersProps {
claimedMembers: string[];
/** Delegated admin pubkeys (subset of members). */
admins?: string[];
/** Optional skate roles per member (pubkey → roles). Renders flex chips. */
rolesByPubkey?: Map<string, CrewRole[]>;
accentText?: string;
accentBorder?: string;
}
/** Skate-role flex chip (🎥 Filmer, 📷 Photographer, …). */
function RoleChip({ role }: { role: CrewRole }) {
const meta = CREW_ROLE_META[role];
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-200">
<span className="leading-none">{meta.emoji}</span> {meta.label}
</span>
);
}
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
if (status === 'confirmed') {
return (
@ -77,7 +90,15 @@ function CrewOnlyChip() {
);
}
function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) {
function MemberRow({
member,
isAdmin,
roles,
}: {
member: CollectiveMember;
isAdmin: boolean;
roles?: CrewRole[];
}) {
const author = useAuthor(member.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(member.pubkey);
@ -86,13 +107,14 @@ function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boo
return (
<Link
to={`/${npub}`}
className="flex items-center gap-3 rounded-lg bg-black/40 p-2 transition-colors hover:bg-black/60"
className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg bg-black/40 p-2 transition-colors hover:bg-black/60"
>
<Avatar className="h-9 w-9">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
{roles?.map((r) => <RoleChip key={r} role={r} />)}
{member.visibility === 'crew' && <CrewOnlyChip />}
{isAdmin && <AdminChip />}
<StatusChip status={member.status} />
@ -129,6 +151,7 @@ export function CollectiveMembers({
identifier,
claimedMembers,
admins = [],
rolesByPubkey,
accentText = 'text-indigo-400',
accentBorder = 'border-gray-700',
}: CollectiveMembersProps) {
@ -200,7 +223,12 @@ export function CollectiveMembers({
) : (
<div className="space-y-2">
{visibleMembers.map((m) => (
<MemberRow key={m.pubkey} member={m} isAdmin={adminSet.has(m.pubkey)} />
<MemberRow
key={m.pubkey}
member={m}
isAdmin={adminSet.has(m.pubkey)}
roles={rolesByPubkey?.get(m.pubkey)}
/>
))}
{/* Collapse all crew-only members into anonymous slots for outsiders */}
{hiddenCount > 0 && (

View file

@ -29,10 +29,13 @@ import {
CREW_FOCUS_DESCRIPTIONS,
CREW_FOCUS_TAG,
CREW_LOOKING_FOR_OPTIONS,
CREW_ROLE_META,
CREW_ROLE_ORDER,
SHOW_CREW_TREASURY,
isValidNoffer,
type SkateCrew,
type CrewFocus,
type CrewRole,
} from '@/lib/types';
import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext';
@ -56,12 +59,16 @@ const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
function MemberRow({
pubkey,
isAdmin,
roles,
onToggleAdmin,
onToggleRole,
onRemove,
}: {
pubkey: string;
isAdmin: boolean;
roles: CrewRole[];
onToggleAdmin: () => void;
onToggleRole: (role: CrewRole) => void;
onRemove: () => void;
}) {
const author = useAuthor(pubkey);
@ -69,39 +76,66 @@ function MemberRow({
const name = metadata?.name ?? genUserName(pubkey);
const npub = nip19.npubEncode(pubkey);
return (
<div className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
{name}
{isAdmin && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
{name}
{isAdmin && (
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
<ShieldCheck className="h-2.5 w-2.5" /> Admin
</span>
)}
</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 shrink-0 gap-1 px-2 text-xs',
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
)}
</p>
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
onClick={onToggleAdmin}
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
>
<ShieldCheck className="h-3.5 w-3.5" />
{isAdmin ? 'Admin' : 'Make admin'}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<X className="h-4 w-4" />
</Button>
</div>
{/* Skate roles — the *functions* on the team. Additive, no powers. */}
<div className="flex flex-wrap items-center gap-1 pl-11">
<span className="text-[10px] font-bold uppercase tracking-wider text-gray-600">Roles:</span>
{CREW_ROLE_ORDER.map((role) => {
const active = roles.includes(role);
const meta = CREW_ROLE_META[role];
return (
<button
key={role}
type="button"
onClick={() => onToggleRole(role)}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
active
? 'border-teal-500 bg-teal-500/20 text-teal-200'
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
</button>
);
})}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 shrink-0 gap-1 px-2 text-xs',
isAdmin ? 'text-teal-300 hover:text-teal-200' : 'text-gray-400 hover:text-teal-300',
)}
onClick={onToggleAdmin}
aria-label={isAdmin ? 'Remove admin role' : 'Make admin'}
>
<ShieldCheck className="h-3.5 w-3.5" />
{isAdmin ? 'Admin' : 'Make admin'}
</Button>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
<X className="h-4 w-4" />
</Button>
</div>
);
}
@ -150,12 +184,33 @@ export function CrewForm({ existing }: CrewFormProps) {
const [memberInput, setMemberInput] = useState('');
const [memberError, setMemberError] = useState<string | null>(null);
// Skate roles per member (pubkey → set of CrewRole). Seed from the existing crew.
const [memberRoles, setMemberRoles] = useState<Record<string, CrewRole[]>>(() => {
const seed: Record<string, CrewRole[]> = {};
for (const { pubkey, role } of existing?.crewRoles ?? []) {
(seed[pubkey] ??= []).push(role);
}
return seed;
});
const toggleAdmin = (pk: string) =>
setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
const toggleMemberRole = (pk: string, role: CrewRole) =>
setMemberRoles((prev) => {
const cur = prev[pk] ?? [];
const next = cur.includes(role) ? cur.filter((r) => r !== role) : [...cur, role];
return { ...prev, [pk]: next };
});
const removeMember = (pk: string) => {
setMembers((prev) => prev.filter((m) => m !== pk));
setAdmins((prev) => prev.filter((a) => a !== pk));
setMemberRoles((prev) => {
const next = { ...prev };
delete next[pk];
return next;
});
};
const [selectedSpots, setSelectedSpots] = useState<string[]>(
(existing?.spotRefs ?? []).map((ref) => {
@ -413,6 +468,16 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(['video', vid]);
}
// Skate roles (filmer / photographer / …) — only for the founder or a
// listed member, so roles can't dangle on non-roster pubkeys.
const roleablePubkeys = new Set([user!.pubkey.toLowerCase(), ...members.map((m) => m.toLowerCase())]);
for (const [pk, roles] of Object.entries(memberRoles)) {
if (!roleablePubkeys.has(pk.toLowerCase())) continue;
for (const role of roles) {
tags.push(['crew-role', pk.toLowerCase(), role]);
}
}
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
@ -709,6 +774,38 @@ export function CrewForm({ existing }: CrewFormProps) {
</span>
</p>
</div>
{/* Founder's own skate roles (you're the leader — are you also the filmer?) */}
{user && (
<div className="rounded-lg border border-teal-500/20 bg-teal-500/[0.04] p-2.5">
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider text-teal-400">
<ShieldCheck className="h-3 w-3" /> Your roles (Team Leader)
</p>
<div className="flex flex-wrap items-center gap-1">
{CREW_ROLE_ORDER.map((role) => {
const active = (memberRoles[user.pubkey.toLowerCase()] ?? []).includes(role);
const meta = CREW_ROLE_META[role];
return (
<button
key={role}
type="button"
onClick={() => toggleMemberRole(user.pubkey.toLowerCase(), role)}
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-bold transition-all active:scale-95',
active
? 'border-teal-500 bg-teal-500/20 text-teal-200'
: 'border-gray-700 bg-gray-900/60 text-gray-500 hover:border-teal-500/40 hover:text-teal-300',
)}
aria-pressed={active}
>
<span className="leading-none">{meta.emoji}</span> {meta.label}
</button>
);
})}
</div>
</div>
)}
<div className="flex gap-2">
<Input
placeholder="npub1… / nprofile1… / hex pubkey"
@ -731,7 +828,9 @@ export function CrewForm({ existing }: CrewFormProps) {
key={pk}
pubkey={pk}
isAdmin={admins.includes(pk)}
roles={memberRoles[pk] ?? []}
onToggleAdmin={() => toggleAdmin(pk)}
onToggleRole={(role) => toggleMemberRole(pk, role)}
onRemove={() => removeMember(pk)}
/>
))}

View file

@ -7,7 +7,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { crewAccent } from '@/lib/crewHelpers';
import type { SkateCrew } from '@/lib/types';
import { CREW_ROLE_META, type CrewRole, type SkateCrew } from '@/lib/types';
import { cn } from '@/lib/utils';
/**
@ -19,11 +19,13 @@ function LeaderCard({
role,
accentHex,
confirmed,
skateRoles,
}: {
pubkey: string;
role: 'leader' | 'captain';
accentHex: string;
confirmed?: boolean;
skateRoles?: CrewRole[];
}) {
const author = useAuthor(pubkey);
const meta = author.data?.metadata;
@ -87,6 +89,18 @@ function LeaderCard({
<Check className="h-3 w-3" /> Verified member
</p>
)}
{skateRoles && skateRoles.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{skateRoles.map((r) => (
<span
key={r}
className="inline-flex items-center gap-0.5 rounded-full bg-white/10 px-1.5 py-0.5 text-[10px] font-bold text-gray-200"
>
<span className="leading-none">{CREW_ROLE_META[r].emoji}</span> {CREW_ROLE_META[r].label}
</span>
))}
</div>
)}
</div>
{/* Quick DM (not for yourself) */}
@ -117,10 +131,13 @@ function LeaderCard({
export function CrewLeadership({
crew,
confirmedAdmins,
rolesByPubkey,
}: {
crew: SkateCrew;
/** Admin pubkeys whose membership is confirmed (for the verified tick). */
confirmedAdmins?: Set<string>;
/** Skate roles per member (pubkey → roles) for role chips. */
rolesByPubkey?: Map<string, CrewRole[]>;
}) {
const accent = crewAccent(crew);
// Admins are always a subset of members; founder is never listed as an admin card.
@ -135,7 +152,12 @@ export function CrewLeadership({
</CardTitle>
</CardHeader>
<CardContent className="space-y-2.5">
<LeaderCard pubkey={crew.pubkey} role="leader" accentHex={accent.hex} />
<LeaderCard
pubkey={crew.pubkey}
role="leader"
accentHex={accent.hex}
skateRoles={rolesByPubkey?.get(crew.pubkey)}
/>
{captains.length > 0 && (
<>
@ -149,6 +171,7 @@ export function CrewLeadership({
role="captain"
accentHex={accent.hex}
confirmed={confirmedAdmins?.has(pk)}
skateRoles={rolesByPubkey?.get(pk)}
/>
))}
</>

View file

@ -1615,10 +1615,68 @@ export interface SkateCrew {
sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag)
crewRoles: CrewMemberRole[]; // Optional skate roles (filmer/photographer/…) assigned to members
createdAt: number;
event: NostrEvent;
}
/**
* Skate roles a crew can assign to a member — the *functions* on a real team
* (the filmer is as vital as the skaters). These are additive to membership and
* purely a flex/label: they grant no powers (only `admin` does that). A member
* can hold a role AND be an admin.
*
* Stored as a dedicated tag on the crew event:
* `["crew-role", <memberPubkey>, <role>]`
*/
export type CrewRole =
| 'filmer'
| 'photographer'
| 'editor'
| 'artist'
| 'manager'
| 'og';
export interface CrewMemberRole {
pubkey: string;
role: CrewRole;
}
/** Human labels + emoji for each crew role (skate-native language). */
export const CREW_ROLE_META: Record<CrewRole, { label: string; emoji: string }> = {
filmer: { label: 'Filmer', emoji: '🎥' },
photographer: { label: 'Photographer', emoji: '📷' },
editor: { label: 'Editor', emoji: '✂️' },
artist: { label: 'Artist', emoji: '🎨' },
manager: { label: 'Team Manager', emoji: '📋' },
og: { label: 'OG', emoji: '👑' },
};
/** All assignable crew roles, in display order. */
export const CREW_ROLE_ORDER: CrewRole[] = ['filmer', 'photographer', 'editor', 'artist', 'manager', 'og'];
const CREW_ROLE_SET = new Set<string>(CREW_ROLE_ORDER);
/**
* Parse `crew-role` tags into member→role assignments. Only roles whose pubkey
* is a valid hex key and whose role is recognised are kept. A member may appear
* multiple times (e.g. filmer + editor).
*/
export function parseCrewRoles(event: NostrEvent): CrewMemberRole[] {
const out: CrewMemberRole[] = [];
const seen = new Set<string>();
for (const [name, pubkey, role] of event.tags) {
if (name !== 'crew-role') continue;
if (!/^[0-9a-f]{64}$/i.test(pubkey ?? '')) continue;
if (!role || !CREW_ROLE_SET.has(role)) continue;
const key = `${pubkey.toLowerCase()}:${role}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ pubkey: pubkey.toLowerCase(), role: role as CrewRole });
}
return out;
}
/**
* A crew's encrypted-group coordinator (Cordn / MLS Delivery Service).
*
@ -2447,6 +2505,9 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
// Crew video / montage — only kept if it parses as a YouTube/Instagram URL.
const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
// Skate roles (filmer/photographer/…) — only for people actually on the roster.
const memberSet = new Set([event.pubkey.toLowerCase(), ...members]);
const crewRoles = parseCrewRoles(event).filter((r) => memberSet.has(r.pubkey));
return {
id: event.id,
@ -2478,6 +2539,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
sponsorRef,
coordinator,
videoUrl,
crewRoles,
createdAt: event.created_at,
event,
};

View file

@ -53,7 +53,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY } from '@/lib/types';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY, type CrewRole } from '@/lib/types';
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import NotFound from './NotFound';
@ -206,6 +206,17 @@ export default function CrewDetailPage() {
const { data: activityMap } = useCrewsActivity(crew ? [crew] : undefined);
const activity = crew ? activityMap?.get(crewCoordinate(crew)) : undefined;
// Skate roles (filmer / photographer / …) grouped by member pubkey.
const rolesByPubkey = (() => {
const map = new Map<string, CrewRole[]>();
for (const { pubkey: pk, role } of crew?.crewRoles ?? []) {
const list = map.get(pk) ?? [];
list.push(role);
map.set(pk, list);
}
return map;
})();
if (isLoading) {
return (
<Layout>
@ -394,7 +405,7 @@ export default function CrewDetailPage() {
{/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
<div className="space-y-6">
{/* Leadership — team leader (founder) + captains (admins) */}
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} rolesByPubkey={rolesByPubkey} />
{/* Private encrypted chat coordinator (Cordn / MLS) */}
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
@ -407,6 +418,7 @@ export default function CrewDetailPage() {
identifier={crew.identifier}
claimedMembers={crew.members}
admins={crew.admins}
rolesByPubkey={rolesByPubkey}
accentText="text-teal-400"
accentBorder="border-teal-500/40"
/>