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:
parent
91045e17ae
commit
cbd30fee00
6 changed files with 265 additions and 38 deletions
3
NIP.md
3
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", "<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue