SkateSpots/src/components/CollectiveMembers.tsx
shakespeare.diy cbd30fee00 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>
2026-09-03 00:07:30 +07:00

246 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}