From cbd30fee0055ef865c984a825733c30f6d79c78c Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 00:07:30 +0700 Subject: [PATCH] Add skate roles to the crew roster (filmer, photographer, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NIP.md | 3 + src/components/CollectiveMembers.tsx | 34 +++++- src/components/CrewForm.tsx | 161 ++++++++++++++++++++----- src/components/crew/CrewLeadership.tsx | 27 ++++- src/lib/types.ts | 62 ++++++++++ src/pages/CrewDetailPage.tsx | 16 ++- 6 files changed, 265 insertions(+), 38 deletions(-) diff --git a/NIP.md b/NIP.md index 05ccec3..20612f1 100644 --- a/NIP.md +++ b/NIP.md @@ -2019,6 +2019,8 @@ bare `p` tag on the crew is only a claim until the member self-labels. ["t", "crew-focus-photo"], ["p", "", "", "admin"], ["p", ""], + ["crew-role", "", "filmer"], + ["crew-role", "", "og"], ["a", "30818::"], ["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", , , ]`. `` 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. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` 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", , ]`, repeatable (a member may hold several). `` ∈ `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 diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx index 412edf8..44ffb38 100644 --- a/src/components/CollectiveMembers.tsx +++ b/src/components/CollectiveMembers.tsx @@ -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; accentText?: string; accentBorder?: string; } +/** Skate-role flex chip (πŸŽ₯ Filmer, πŸ“· Photographer, …). */ +function RoleChip({ role }: { role: CrewRole }) { + const meta = CREW_ROLE_META[role]; + return ( + + {meta.emoji} {meta.label} + + ); +} + 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 ( {name.slice(0, 2).toUpperCase()} {name} + {roles?.map((r) => )} {member.visibility === 'crew' && } {isAdmin && } @@ -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({ ) : (
{visibleMembers.map((m) => ( - + ))} {/* Collapse all crew-only members into anonymous slots for outsiders */} {hiddenCount > 0 && ( diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 0e06b43..3128b95 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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 ( -
- - - {name.slice(0, 2).toUpperCase()} - -
-

- {name} - {isAdmin && ( - - Admin - +

+
+ + + {name.slice(0, 2).toUpperCase()} + +
+

+ {name} + {isAdmin && ( + + Admin + + )} +

+

{npub.slice(0, 16)}…

+
+ + +
+ + {/* Skate roles β€” the *functions* on the team. Additive, no powers. */} +
+ Roles: + {CREW_ROLE_ORDER.map((role) => { + const active = roles.includes(role); + const meta = CREW_ROLE_META[role]; + return ( + + ); + })}
- -
); } @@ -150,12 +184,33 @@ export function CrewForm({ existing }: CrewFormProps) { const [memberInput, setMemberInput] = useState(''); const [memberError, setMemberError] = useState(null); + // Skate roles per member (pubkey β†’ set of CrewRole). Seed from the existing crew. + const [memberRoles, setMemberRoles] = useState>(() => { + const seed: Record = {}; + 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( (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) {

+ + {/* Founder's own skate roles (you're the leader β€” are you also the filmer?) */} + {user && ( +
+

+ Your roles (Team Leader) +

+
+ {CREW_ROLE_ORDER.map((role) => { + const active = (memberRoles[user.pubkey.toLowerCase()] ?? []).includes(role); + const meta = CREW_ROLE_META[role]; + return ( + + ); + })} +
+
+ )} +
toggleAdmin(pk)} + onToggleRole={(role) => toggleMemberRole(pk, role)} onRemove={() => removeMember(pk)} /> ))} diff --git a/src/components/crew/CrewLeadership.tsx b/src/components/crew/CrewLeadership.tsx index f76d8bf..ba1ca5d 100644 --- a/src/components/crew/CrewLeadership.tsx +++ b/src/components/crew/CrewLeadership.tsx @@ -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({ Verified member

)} + {skateRoles && skateRoles.length > 0 && ( +
+ {skateRoles.map((r) => ( + + {CREW_ROLE_META[r].emoji} {CREW_ROLE_META[r].label} + + ))} +
+ )}
{/* 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; + /** Skate roles per member (pubkey β†’ roles) for role chips. */ + rolesByPubkey?: Map; }) { 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({ - + {captains.length > 0 && ( <> @@ -149,6 +171,7 @@ export function CrewLeadership({ role="captain" accentHex={accent.hex} confirmed={confirmedAdmins?.has(pk)} + skateRoles={rolesByPubkey?.get(pk)} /> ))} diff --git a/src/lib/types.ts b/src/lib/types.ts index b8ad804..a77d942 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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", , ]` + */ +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 = { + 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(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(); + 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, }; diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index cafce2d..a851037 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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(); + 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 ( @@ -394,7 +405,7 @@ export default function CrewDetailPage() { {/* Sidebar β€” ordered: leadership β†’ who β†’ support β†’ band β†’ connections β†’ founder */}
{/* Leadership β€” team leader (founder) + captains (admins) */} - + {/* Private encrypted chat coordinator (Cordn / MLS) */} @@ -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" />