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>
246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
import { Link } from 'react-router-dom';
|
||
import { nip19 } from 'nostr-tools';
|
||
import { Users, Check, Clock, ShieldCheck, EyeOff } from 'lucide-react';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import {
|
||
Tooltip,
|
||
TooltipContent,
|
||
TooltipProvider,
|
||
TooltipTrigger,
|
||
} from '@/components/ui/tooltip';
|
||
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 {
|
||
kind: number;
|
||
founderPubkey: string;
|
||
identifier: string;
|
||
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 (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/15 px-2 py-0.5 text-xs font-bold text-green-500">
|
||
<Check className="h-3 w-3" /> Confirmed
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs font-bold text-amber-500">
|
||
<Clock className="h-3 w-3" /> Pending
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
Listed by the collective — awaiting this member’s confirmation.
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
function AdminChip() {
|
||
return (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-teal-500/15 px-2 py-0.5 text-xs font-bold text-teal-300">
|
||
<ShieldCheck className="h-3 w-3" /> Admin
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function CrewOnlyChip() {
|
||
return (
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs font-bold text-gray-300">
|
||
<EyeOff className="h-3 w-3" /> Crew only
|
||
</span>
|
||
</TooltipTrigger>
|
||
<TooltipContent>
|
||
This member chose to be visible only to crew-mates. You can see them
|
||
because you're in the crew.
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
);
|
||
}
|
||
|
||
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);
|
||
const npub = nip19.npubEncode(member.pubkey);
|
||
|
||
return (
|
||
<Link
|
||
to={`/${npub}`}
|
||
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} />
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/** Anonymous placeholder shown to NON-members for a `crew`-only member. */
|
||
function HiddenMemberRow() {
|
||
return (
|
||
<div className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
|
||
<Avatar className="h-9 w-9">
|
||
<AvatarFallback className="bg-white/5 text-gray-600">
|
||
<EyeOff className="h-4 w-4" />
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-gray-500 italic">
|
||
Hidden member
|
||
</span>
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-white/5 px-2 py-0.5 text-xs font-bold text-gray-500">
|
||
<EyeOff className="h-3 w-3" /> Crew only
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Renders a collective's members with confirmed / pending trust chips.
|
||
* Returns null for individuals or collectives with no listed members.
|
||
*/
|
||
export function CollectiveMembers({
|
||
kind,
|
||
founderPubkey,
|
||
identifier,
|
||
claimedMembers,
|
||
admins = [],
|
||
rolesByPubkey,
|
||
accentText = 'text-indigo-400',
|
||
accentBorder = 'border-gray-700',
|
||
}: CollectiveMembersProps) {
|
||
const { user } = useCurrentUser();
|
||
const { data: members, isLoading } = useCollectiveMembers({
|
||
kind,
|
||
founderPubkey,
|
||
identifier,
|
||
claimedMembers,
|
||
enabled: claimedMembers.length > 0,
|
||
});
|
||
|
||
if (claimedMembers.length === 0) return null;
|
||
|
||
const adminSet = new Set(admins);
|
||
const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0;
|
||
|
||
// The viewer counts as a crew-mate (can see crew-only members) if they are
|
||
// the founder or a claimed member of this crew.
|
||
const viewerIsCrewmate =
|
||
!!user && (user.pubkey === founderPubkey || claimedMembers.includes(user.pubkey));
|
||
|
||
// Admins first, then the hook's existing confirmed-first ordering.
|
||
const orderedMembers = members
|
||
? [...members].sort((a, b) => {
|
||
const aa = adminSet.has(a.pubkey) ? 1 : 0;
|
||
const ba = adminSet.has(b.pubkey) ? 1 : 0;
|
||
return ba - aa;
|
||
})
|
||
: members;
|
||
|
||
// Split into what the viewer may see vs. anonymised "hidden member" slots.
|
||
// A crew-only member is always visible to themselves.
|
||
const isHiddenFromViewer = (m: CollectiveMember) =>
|
||
m.visibility === 'crew' && !viewerIsCrewmate && m.pubkey !== user?.pubkey;
|
||
|
||
const visibleMembers = orderedMembers?.filter((m) => !isHiddenFromViewer(m)) ?? [];
|
||
const hiddenCount = orderedMembers?.filter(isHiddenFromViewer).length ?? 0;
|
||
|
||
return (
|
||
<Card className={cn('bg-black/90 border-2', accentBorder)}>
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||
<Users className={cn('h-5 w-5', accentText)} />
|
||
Members · {claimedMembers.length}
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-2">
|
||
<p className="flex items-start gap-1.5 text-xs text-gray-400">
|
||
<ShieldCheck className={cn('mt-0.5 h-3.5 w-3.5 shrink-0', accentText)} />
|
||
<span>
|
||
{confirmedCount > 0
|
||
? `${confirmedCount} of ${claimedMembers.length} verified — each confirmed member signed with their own Nostr key.`
|
||
: 'Membership is verified per member. Each member confirms with their own Nostr key.'}
|
||
{admins.length > 0 && ` ${admins.length} admin${admins.length !== 1 ? 's' : ''} help run the crew.`}
|
||
</span>
|
||
</p>
|
||
|
||
{isLoading ? (
|
||
<div className="space-y-2">
|
||
{claimedMembers.slice(0, 3).map((_, i) => (
|
||
<div key={i} className="flex items-center gap-3 rounded-lg bg-black/40 p-2">
|
||
<Skeleton className="h-9 w-9 rounded-full" />
|
||
<Skeleton className="h-4 flex-1" />
|
||
<Skeleton className="h-5 w-20 rounded-full" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{visibleMembers.map((m) => (
|
||
<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 && (
|
||
<>
|
||
{Array.from({ length: hiddenCount }).map((_, i) => (
|
||
<HiddenMemberRow key={`hidden-${i}`} />
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|