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"],
|
["t", "crew-focus-photo"],
|
||||||
["p", "<memberPubkey1>", "", "admin"],
|
["p", "<memberPubkey1>", "", "admin"],
|
||||||
["p", "<memberPubkey2>"],
|
["p", "<memberPubkey2>"],
|
||||||
|
["crew-role", "<memberPubkey2>", "filmer"],
|
||||||
|
["crew-role", "<founder>", "og"],
|
||||||
["a", "30818:<spotFounder>:<spotDtag>"],
|
["a", "30818:<spotFounder>:<spotDtag>"],
|
||||||
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
|
["nip05", "barcelonanightcrew@bcnnightcrew.example"],
|
||||||
["website", "https://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. |
|
| `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. |
|
| `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. |
|
| `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
|
### Crew-Hosted Events
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
|
import { useCollectiveMembers, type CollectiveMember } from '@/hooks/useCollectiveMembers';
|
||||||
|
import { CREW_ROLE_META, type CrewRole } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface CollectiveMembersProps {
|
interface CollectiveMembersProps {
|
||||||
|
|
@ -23,10 +24,22 @@ interface CollectiveMembersProps {
|
||||||
claimedMembers: string[];
|
claimedMembers: string[];
|
||||||
/** Delegated admin pubkeys (subset of members). */
|
/** Delegated admin pubkeys (subset of members). */
|
||||||
admins?: string[];
|
admins?: string[];
|
||||||
|
/** Optional skate roles per member (pubkey → roles). Renders flex chips. */
|
||||||
|
rolesByPubkey?: Map<string, CrewRole[]>;
|
||||||
accentText?: string;
|
accentText?: string;
|
||||||
accentBorder?: 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'] }) {
|
function StatusChip({ status }: { status: CollectiveMember['status'] }) {
|
||||||
if (status === 'confirmed') {
|
if (status === 'confirmed') {
|
||||||
return (
|
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 author = useAuthor(member.pubkey);
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const name = metadata?.name ?? genUserName(member.pubkey);
|
const name = metadata?.name ?? genUserName(member.pubkey);
|
||||||
|
|
@ -86,13 +107,14 @@ function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boo
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/${npub}`}
|
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">
|
<Avatar className="h-9 w-9">
|
||||||
<AvatarImage src={metadata?.picture} alt={name} />
|
<AvatarImage src={metadata?.picture} alt={name} />
|
||||||
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-200">{name}</span>
|
<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 />}
|
{member.visibility === 'crew' && <CrewOnlyChip />}
|
||||||
{isAdmin && <AdminChip />}
|
{isAdmin && <AdminChip />}
|
||||||
<StatusChip status={member.status} />
|
<StatusChip status={member.status} />
|
||||||
|
|
@ -129,6 +151,7 @@ export function CollectiveMembers({
|
||||||
identifier,
|
identifier,
|
||||||
claimedMembers,
|
claimedMembers,
|
||||||
admins = [],
|
admins = [],
|
||||||
|
rolesByPubkey,
|
||||||
accentText = 'text-indigo-400',
|
accentText = 'text-indigo-400',
|
||||||
accentBorder = 'border-gray-700',
|
accentBorder = 'border-gray-700',
|
||||||
}: CollectiveMembersProps) {
|
}: CollectiveMembersProps) {
|
||||||
|
|
@ -200,7 +223,12 @@ export function CollectiveMembers({
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{visibleMembers.map((m) => (
|
{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 */}
|
{/* Collapse all crew-only members into anonymous slots for outsiders */}
|
||||||
{hiddenCount > 0 && (
|
{hiddenCount > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,13 @@ import {
|
||||||
CREW_FOCUS_DESCRIPTIONS,
|
CREW_FOCUS_DESCRIPTIONS,
|
||||||
CREW_FOCUS_TAG,
|
CREW_FOCUS_TAG,
|
||||||
CREW_LOOKING_FOR_OPTIONS,
|
CREW_LOOKING_FOR_OPTIONS,
|
||||||
|
CREW_ROLE_META,
|
||||||
|
CREW_ROLE_ORDER,
|
||||||
SHOW_CREW_TREASURY,
|
SHOW_CREW_TREASURY,
|
||||||
isValidNoffer,
|
isValidNoffer,
|
||||||
type SkateCrew,
|
type SkateCrew,
|
||||||
type CrewFocus,
|
type CrewFocus,
|
||||||
|
type CrewRole,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||||
import { useAppContext } from '@/hooks/useAppContext';
|
import { useAppContext } from '@/hooks/useAppContext';
|
||||||
|
|
@ -56,12 +59,16 @@ const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
||||||
function MemberRow({
|
function MemberRow({
|
||||||
pubkey,
|
pubkey,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
roles,
|
||||||
onToggleAdmin,
|
onToggleAdmin,
|
||||||
|
onToggleRole,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
roles: CrewRole[];
|
||||||
onToggleAdmin: () => void;
|
onToggleAdmin: () => void;
|
||||||
|
onToggleRole: (role: CrewRole) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
|
|
@ -69,39 +76,66 @@ function MemberRow({
|
||||||
const name = metadata?.name ?? genUserName(pubkey);
|
const name = metadata?.name ?? genUserName(pubkey);
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
<div className="space-y-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
||||||
<Avatar className="h-8 w-8">
|
<div className="flex items-center gap-3">
|
||||||
<AvatarImage src={metadata?.picture} alt={name} />
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
<AvatarImage src={metadata?.picture} alt={name} />
|
||||||
</Avatar>
|
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
<div className="min-w-0 flex-1">
|
</Avatar>
|
||||||
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
|
<div className="min-w-0 flex-1">
|
||||||
{name}
|
<p className="flex items-center gap-1.5 truncate text-sm font-medium">
|
||||||
{isAdmin && (
|
{name}
|
||||||
<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">
|
{isAdmin && (
|
||||||
<ShieldCheck className="h-2.5 w-2.5" /> Admin
|
<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">
|
||||||
</span>
|
<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>
|
onClick={onToggleAdmin}
|
||||||
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -150,12 +184,33 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
const [memberInput, setMemberInput] = useState('');
|
const [memberInput, setMemberInput] = useState('');
|
||||||
const [memberError, setMemberError] = useState<string | null>(null);
|
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) =>
|
const toggleAdmin = (pk: string) =>
|
||||||
setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk]));
|
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) => {
|
const removeMember = (pk: string) => {
|
||||||
setMembers((prev) => prev.filter((m) => m !== pk));
|
setMembers((prev) => prev.filter((m) => m !== pk));
|
||||||
setAdmins((prev) => prev.filter((a) => a !== pk));
|
setAdmins((prev) => prev.filter((a) => a !== pk));
|
||||||
|
setMemberRoles((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[pk];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>(
|
const [selectedSpots, setSelectedSpots] = useState<string[]>(
|
||||||
(existing?.spotRefs ?? []).map((ref) => {
|
(existing?.spotRefs ?? []).map((ref) => {
|
||||||
|
|
@ -413,6 +468,16 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
tags.push(['video', vid]);
|
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 });
|
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||||
|
|
||||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
||||||
|
|
@ -709,6 +774,38 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="npub1… / nprofile1… / hex pubkey"
|
placeholder="npub1… / nprofile1… / hex pubkey"
|
||||||
|
|
@ -731,7 +828,9 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
key={pk}
|
key={pk}
|
||||||
pubkey={pk}
|
pubkey={pk}
|
||||||
isAdmin={admins.includes(pk)}
|
isAdmin={admins.includes(pk)}
|
||||||
|
roles={memberRoles[pk] ?? []}
|
||||||
onToggleAdmin={() => toggleAdmin(pk)}
|
onToggleAdmin={() => toggleAdmin(pk)}
|
||||||
|
onToggleRole={(role) => toggleMemberRole(pk, role)}
|
||||||
onRemove={() => removeMember(pk)}
|
onRemove={() => removeMember(pk)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { crewAccent } from '@/lib/crewHelpers';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,11 +19,13 @@ function LeaderCard({
|
||||||
role,
|
role,
|
||||||
accentHex,
|
accentHex,
|
||||||
confirmed,
|
confirmed,
|
||||||
|
skateRoles,
|
||||||
}: {
|
}: {
|
||||||
pubkey: string;
|
pubkey: string;
|
||||||
role: 'leader' | 'captain';
|
role: 'leader' | 'captain';
|
||||||
accentHex: string;
|
accentHex: string;
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
|
skateRoles?: CrewRole[];
|
||||||
}) {
|
}) {
|
||||||
const author = useAuthor(pubkey);
|
const author = useAuthor(pubkey);
|
||||||
const meta = author.data?.metadata;
|
const meta = author.data?.metadata;
|
||||||
|
|
@ -87,6 +89,18 @@ function LeaderCard({
|
||||||
<Check className="h-3 w-3" /> Verified member
|
<Check className="h-3 w-3" /> Verified member
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Quick DM (not for yourself) */}
|
{/* Quick DM (not for yourself) */}
|
||||||
|
|
@ -117,10 +131,13 @@ function LeaderCard({
|
||||||
export function CrewLeadership({
|
export function CrewLeadership({
|
||||||
crew,
|
crew,
|
||||||
confirmedAdmins,
|
confirmedAdmins,
|
||||||
|
rolesByPubkey,
|
||||||
}: {
|
}: {
|
||||||
crew: SkateCrew;
|
crew: SkateCrew;
|
||||||
/** Admin pubkeys whose membership is confirmed (for the verified tick). */
|
/** Admin pubkeys whose membership is confirmed (for the verified tick). */
|
||||||
confirmedAdmins?: Set<string>;
|
confirmedAdmins?: Set<string>;
|
||||||
|
/** Skate roles per member (pubkey → roles) for role chips. */
|
||||||
|
rolesByPubkey?: Map<string, CrewRole[]>;
|
||||||
}) {
|
}) {
|
||||||
const accent = crewAccent(crew);
|
const accent = crewAccent(crew);
|
||||||
// Admins are always a subset of members; founder is never listed as an admin card.
|
// Admins are always a subset of members; founder is never listed as an admin card.
|
||||||
|
|
@ -135,7 +152,12 @@ export function CrewLeadership({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2.5">
|
<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 && (
|
{captains.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -149,6 +171,7 @@ export function CrewLeadership({
|
||||||
role="captain"
|
role="captain"
|
||||||
accentHex={accent.hex}
|
accentHex={accent.hex}
|
||||||
confirmed={confirmedAdmins?.has(pk)}
|
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
|
sponsorRef?: string; // Sponsoring skate shop coordinate (30017:pubkey:d) via `sponsor` tag
|
||||||
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
|
coordinator?: CrewCoordinator; // Cordn/MLS encrypted-chat coordinator for this crew
|
||||||
videoUrl?: string; // Crew's headline video / montage — YouTube or Instagram URL (`video` tag)
|
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;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
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).
|
* 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.
|
// Crew video / montage — only kept if it parses as a YouTube/Instagram URL.
|
||||||
const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
|
const videoRaw = event.tags.find(([name]) => name === 'video')?.[1]?.trim();
|
||||||
const videoUrl = videoRaw && parseCrewVideo(videoRaw) ? videoRaw : undefined;
|
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 {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -2478,6 +2539,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
sponsorRef,
|
sponsorRef,
|
||||||
coordinator,
|
coordinator,
|
||||||
videoUrl,
|
videoUrl,
|
||||||
|
crewRoles,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
|
|
@ -206,6 +206,17 @@ export default function CrewDetailPage() {
|
||||||
const { data: activityMap } = useCrewsActivity(crew ? [crew] : undefined);
|
const { data: activityMap } = useCrewsActivity(crew ? [crew] : undefined);
|
||||||
const activity = crew ? activityMap?.get(crewCoordinate(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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -394,7 +405,7 @@ export default function CrewDetailPage() {
|
||||||
{/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
|
{/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Leadership — team leader (founder) + captains (admins) */}
|
{/* Leadership — team leader (founder) + captains (admins) */}
|
||||||
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
|
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} rolesByPubkey={rolesByPubkey} />
|
||||||
|
|
||||||
{/* Private encrypted chat coordinator (Cordn / MLS) */}
|
{/* Private encrypted chat coordinator (Cordn / MLS) */}
|
||||||
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
|
<CrewCoordinatorCard crew={crew} isFounder={isFounder} />
|
||||||
|
|
@ -407,6 +418,7 @@ export default function CrewDetailPage() {
|
||||||
identifier={crew.identifier}
|
identifier={crew.identifier}
|
||||||
claimedMembers={crew.members}
|
claimedMembers={crew.members}
|
||||||
admins={crew.admins}
|
admins={crew.admins}
|
||||||
|
rolesByPubkey={rolesByPubkey}
|
||||||
accentText="text-teal-400"
|
accentText="text-teal-400"
|
||||||
accentBorder="border-teal-500/40"
|
accentBorder="border-teal-500/40"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue