From 40e4a27565e2b49ab7b33612d5090f2e200542df Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 16:46:20 +0700 Subject: [PATCH] Add crew NIP-05 (any-domain, founder-verified) + founder/delegated-admin governance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SkateCrew: add nip05 + admins fields; parse `admin` role marker on p tags (admins = subset of members) - CrewForm: NIP-05 input + per-member "Make admin" toggle; write nip05 tag + admin-marked p tags (works on create & edit via shared form) - useCrewNip05: verify crew handle on ANY domain against the founder pubkey (verified/mismatch/unreachable), CORS-proxy fallback + caching - CrewNip05Badge: green ✓ / red ⚠ / muted handle states with tooltips - CollectiveMembers: show Admin chips, sort admins first, admin count in summary - useCrewRole: centralized founder/admin/member/none roles (canManage, canEditProfile) — founder owns identity, admins get delegated powers with their own keys; egalitarian shared-editing intentionally unsupported - CrewDetailPage: NIP-05 badge by crew name, viewer role badges (admin/member) - NIP.md: document crew nip05 verification, admin role marker, and governance/conflict-resolution model Co-authored-by: shakespeare.diy --- NIP.md | 51 ++++++++++++++- src/components/CollectiveMembers.tsx | 29 ++++++++- src/components/CrewForm.tsx | 93 ++++++++++++++++++++++++++-- src/components/CrewNip05Badge.tsx | 91 +++++++++++++++++++++++++++ src/hooks/useCrewNip05.ts | 88 ++++++++++++++++++++++++++ src/hooks/useCrewRole.ts | 71 +++++++++++++++++++++ src/lib/types.ts | 28 +++++++++ src/pages/CrewDetailPage.tsx | 36 +++++++---- 8 files changed, 466 insertions(+), 21 deletions(-) create mode 100644 src/components/CrewNip05Badge.tsx create mode 100644 src/hooks/useCrewNip05.ts create mode 100644 src/hooks/useCrewRole.ts diff --git a/NIP.md b/NIP.md index aa6f2d8..9b1e795 100644 --- a/NIP.md +++ b/NIP.md @@ -1716,9 +1716,10 @@ bare `p` tag on the crew is only a claim until the member self-labels. ["founded", "2019"], ["t", "street"], ["t", "transition"], - ["p", ""], + ["p", "", "", "admin"], ["p", ""], ["a", "30818::"], + ["nip05", "barcelonanightcrew@bcnnightcrew.example"], ["website", "https://bcnnightcrew.example"], ["noffer", "noffer1qqs..."], ["alt", "Skate crew: Barcelona Night Crew"] @@ -1743,8 +1744,9 @@ bare `p` tag on the crew is only a claim until the member self-labels. | `location` | Human-readable home location | | `founded` | Year founded (cosmetic) | | `t: ` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. | -| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). | +| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). | | `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | +| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | | `website`, `instagram`, `social` | Links | | `noffer` | Optional shared tip jar (CLINK Offer — see below) | @@ -1755,6 +1757,51 @@ belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's `a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed / pending chips and removal semantics are identical to Lab collectives. +### Governance (founder + delegated admins) + +A crew's identity is a single **addressable event authored by the founder**. +On Nostr, an addressable event can only be overwritten by its author's key, so +the founder's pubkey is the crew's single source of truth — **conflicts are +impossible at the data layer** (a fork by anyone else is a *different* event +under a *different* author and is ignored by clients querying the founder's +coordinate). + +Roles: + +| Role | Determined by | Powers | +|------|---------------|--------| +| **Founder** | Authored the crew event | Sole owner of the crew identity. Only role that can **edit the profile** (name, logo, members, admins, NIP-05, etc.). | +| **Admin** | A confirmed member whose `p` tag carries the `"admin"` marker | Delegated *powers* exercised with the admin's **own key**: invite/vouch members, moderate the crew feed & chat, represent the crew. Cannot rewrite the profile. | +| **Member** | Listed `p` tag + self-attested | Appears in the crew, posts to the feed/chat. | + +Admins are always a **subset of members** — a pubkey marked `admin` that is not +also a confirmed member is treated as a plain (pending) member until it attests. + +> **Why not egalitarian shared editing?** Letting arbitrary members overwrite +> the crew profile would require either a **shared private key** (a single leak +> hijacks the crew) or a **consensus/multisig layer** (heavy and +> non-interoperable). SkateSpots deliberately uses the owner + delegated-mods +> model (like GitHub orgs, Discord, and NIP-72 communities): the founder owns +> the record, admins wield delegated powers with their own keys. + +### NIP-05 Verification + +The optional `nip05` tag is a crew handle on **any domain the crew controls** +(fully decentralized — SkateSpots does not gatekeep the handle). Because the +crew event is authored by the founder, a client verifies the handle by fetching +`https:///.well-known/nostr.json?name=` and checking that the +returned pubkey equals the **founder** pubkey: + +| Result | Meaning | UI | +|--------|---------|----| +| name → founder pubkey | Verified | Green ✓ handle | +| name → different pubkey | Impersonation / stale | Red ⚠ handle | +| domain/JSON unreachable | Can't verify right now | Muted handle | +| no `nip05` | — | (nothing) | + +Verification is fetched directly with a CORS-proxy fallback and cached +client-side. + ### Map Integration Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking diff --git a/src/components/CollectiveMembers.tsx b/src/components/CollectiveMembers.tsx index 397e3e4..91ce8ed 100644 --- a/src/components/CollectiveMembers.tsx +++ b/src/components/CollectiveMembers.tsx @@ -20,6 +20,8 @@ interface CollectiveMembersProps { founderPubkey: string; identifier: string; claimedMembers: string[]; + /** Delegated admin pubkeys (subset of members). */ + admins?: string[]; accentText?: string; accentBorder?: string; } @@ -48,7 +50,15 @@ function StatusChip({ status }: { status: CollectiveMember['status'] }) { ); } -function MemberRow({ member }: { member: CollectiveMember }) { +function AdminChip() { + return ( + + Admin + + ); +} + +function MemberRow({ member, isAdmin }: { member: CollectiveMember; isAdmin: boolean }) { const author = useAuthor(member.pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(member.pubkey); @@ -64,6 +74,7 @@ function MemberRow({ member }: { member: CollectiveMember }) { {name.slice(0, 2).toUpperCase()} {name} + {isAdmin && } ); @@ -78,6 +89,7 @@ export function CollectiveMembers({ founderPubkey, identifier, claimedMembers, + admins = [], accentText = 'text-indigo-400', accentBorder = 'border-gray-700', }: CollectiveMembersProps) { @@ -91,8 +103,18 @@ export function CollectiveMembers({ if (claimedMembers.length === 0) return null; + const adminSet = new Set(admins); const confirmedCount = members?.filter((m) => m.status === 'confirmed').length ?? 0; + // 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; + return ( @@ -108,6 +130,7 @@ export function CollectiveMembers({ {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.`}

@@ -123,8 +146,8 @@ export function CollectiveMembers({ ) : (
- {members?.map((m) => ( - + {orderedMembers?.map((m) => ( + ))}
)} diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 4660b53..fc411f2 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -25,12 +25,22 @@ import { type SkateCrew, } from '@/lib/types'; import { cn } from '@/lib/utils'; -import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react'; +import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign } from 'lucide-react'; const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS); const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black'; -function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) { +function MemberRow({ + pubkey, + isAdmin, + onToggleAdmin, + onRemove, +}: { + pubkey: string; + isAdmin: boolean; + onToggleAdmin: () => void; + onRemove: () => void; +}) { const author = useAuthor(pubkey); const metadata = author.data?.metadata; const name = metadata?.name ?? genUserName(pubkey); @@ -42,9 +52,30 @@ function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void {name.slice(0, 2).toUpperCase()}
-

{name}

+

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

{npub.slice(0, 16)}…

+ @@ -85,10 +116,20 @@ export function CrewForm({ existing }: CrewFormProps) { const [instagram, setInstagram] = useState(existing?.instagram ?? ''); const [socials, setSocials] = useState(existing?.socials ?? []); const [socialInput, setSocialInput] = useState(''); + const [nip05, setNip05] = useState(existing?.nip05 ?? ''); const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [members, setMembers] = useState(existing?.members ?? []); + const [admins, setAdmins] = useState(existing?.admins ?? []); const [memberInput, setMemberInput] = useState(''); const [memberError, setMemberError] = useState(null); + + const toggleAdmin = (pk: string) => + setAdmins((prev) => (prev.includes(pk) ? prev.filter((x) => x !== pk) : [...prev, pk])); + + const removeMember = (pk: string) => { + setMembers((prev) => prev.filter((m) => m !== pk)); + setAdmins((prev) => prev.filter((a) => a !== pk)); + }; const [selectedSpots, setSelectedSpots] = useState( (existing?.spotRefs ?? []).map((ref) => { const parts = ref.split(':'); @@ -232,9 +273,17 @@ export function CrewForm({ existing }: CrewFormProps) { for (const s of socials) { if (s.trim()) tags.push(['social', s.trim()]); } + if (nip05.trim()) tags.push(['nip05', nip05.trim().toLowerCase()]); if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]); - for (const pk of members) tags.push(['p', pk]); + // Members: admins get an `admin` role marker on their `p` tag. + for (const pk of members) { + if (admins.includes(pk)) { + tags.push(['p', pk, '', 'admin']); + } else { + tags.push(['p', pk]); + } + } for (const spotId of selectedSpots) { const [spotPubkey, spotIdentifier] = spotId.split(':'); @@ -346,6 +395,14 @@ export function CrewForm({ existing }: CrewFormProps) {

Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.

+

+ + + Make trusted members admins — they can invite members + and moderate the crew feed & chat with their own keys. You (the founder) always stay the + owner of the crew's identity. + +

0 && (
{members.map((pk) => ( - setMembers(members.filter((m) => m !== pk))} /> + toggleAdmin(pk)} + onRemove={() => removeMember(pk)} + /> ))}
)} @@ -408,6 +471,26 @@ export function CrewForm({ existing }: CrewFormProps) { )}
+ {/* Crew NIP-05 identity */} +
+ +

+ A verifiable handle you control on any domain, e.g. crew@yourdomain.com. + It's verified against this crew's founder key — a green check shows when the domain confirms it. +

+ setNip05(e.target.value)} + spellCheck={false} + autoCapitalize="none" + className="bg-gray-800 border-gray-700 font-mono text-sm" + /> +
+ {/* Links */}
diff --git a/src/components/CrewNip05Badge.tsx b/src/components/CrewNip05Badge.tsx new file mode 100644 index 0000000..4ae0e62 --- /dev/null +++ b/src/components/CrewNip05Badge.tsx @@ -0,0 +1,91 @@ +import { BadgeCheck, Loader2, AlertTriangle } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { useCrewNip05 } from '@/hooks/useCrewNip05'; +import { cn } from '@/lib/utils'; + +interface CrewNip05BadgeProps { + nip05?: string; + founderPubkey?: string; + className?: string; +} + +/** + * Verified-handle badge for a crew's optional NIP-05. + * + * - `verified`: green check + the handle. The domain confirms the founder key. + * - `mismatch`: red warning — the domain points the handle at a different key. + * - `unreachable`: muted handle (shown but unverified) while the domain is down. + * - `none`: renders nothing. + */ +export function CrewNip05Badge({ nip05, founderPubkey, className }: CrewNip05BadgeProps) { + const { data, isLoading } = useCrewNip05(nip05, founderPubkey); + + if (!nip05) return null; + + const status = data?.status ?? (isLoading ? 'loading' : 'unreachable'); + const handle = nip05.trim().toLowerCase(); + + if (status === 'mismatch') { + return ( + + + + + {handle} + + + + This domain points the handle at a different Nostr key than the crew founder. Not verified. + + + + ); + } + + if (status === 'verified') { + return ( + + + + + {handle} + + + + Verified — the domain confirms this handle belongs to the crew's founder key. + + + + ); + } + + // loading / unreachable — show the handle, muted. + return ( + + {status === 'loading' ? ( + + ) : null} + {handle} + + ); +} diff --git a/src/hooks/useCrewNip05.ts b/src/hooks/useCrewNip05.ts new file mode 100644 index 0000000..9a0e4f9 --- /dev/null +++ b/src/hooks/useCrewNip05.ts @@ -0,0 +1,88 @@ +import { useQuery } from '@tanstack/react-query'; + +export type Nip05Status = 'none' | 'verified' | 'mismatch' | 'unreachable'; + +interface Nip05Result { + status: Nip05Status; + /** The pubkey the domain claims for this name, if any. */ + resolvedPubkey?: string; +} + +/** Basic `local@domain.tld` shape check. */ +function parseNip05(nip05: string): { name: string; domain: string } | null { + const trimmed = nip05.trim().toLowerCase(); + const at = trimmed.indexOf('@'); + if (at <= 0) return null; + const name = trimmed.slice(0, at); + const domain = trimmed.slice(at + 1); + if (!/^[a-z0-9._-]+$/.test(name)) return null; + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null; + return { name, domain }; +} + +async function fetchWellKnown(name: string, domain: string, signal: AbortSignal) { + const target = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`; + + // Try direct first; fall back to the CORS proxy (many nostr.json hosts don't + // send permissive CORS headers). + const attempts = [ + target, + `https://proxy.shakespeare.diy/?url=${encodeURIComponent(target)}`, + ]; + + for (const url of attempts) { + try { + const res = await fetch(url, { signal, headers: { Accept: 'application/json' } }); + if (!res.ok) continue; + const data = await res.json(); + const names = data?.names; + if (names && typeof names === 'object') { + return names as Record; + } + } catch { + // try next attempt + } + } + return null; +} + +/** + * Verify a crew's optional NIP-05 identifier against its founder pubkey. + * + * The crew profile is authored by the founder, so a NIP-05 is legitimate only if + * the domain's `.well-known/nostr.json` maps the name to that same founder key. + * This works with ANY domain the crew controls (fully decentralized) — SkateSpots + * does not gatekeep the handle. + * + * States: + * - `verified`: domain maps the name to the founder pubkey. Trusted ✓ + * - `mismatch`: domain maps the name to a DIFFERENT pubkey. Do not trust. + * - `unreachable`: the domain / nostr.json couldn't be fetched (network/CORS). + * - `none`: no NIP-05 set, or malformed. + */ +export function useCrewNip05(nip05: string | undefined, founderPubkey: string | undefined) { + return useQuery({ + queryKey: ['crew-nip05', nip05, founderPubkey], + queryFn: async (c): Promise => { + if (!nip05 || !founderPubkey) return { status: 'none' }; + const parsed = parseNip05(nip05); + if (!parsed) return { status: 'none' }; + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + const names = await fetchWellKnown(parsed.name, parsed.domain, signal); + if (!names) return { status: 'unreachable' }; + + const resolved = names[parsed.name]?.toLowerCase(); + if (!resolved) return { status: 'unreachable' }; + + if (resolved === founderPubkey.toLowerCase()) { + return { status: 'verified', resolvedPubkey: resolved }; + } + return { status: 'mismatch', resolvedPubkey: resolved }; + }, + enabled: !!nip05 && !!founderPubkey, + staleTime: 10 * 60 * 1000, // 10 min + gcTime: 30 * 60 * 1000, + retry: 1, + }); +} diff --git a/src/hooks/useCrewRole.ts b/src/hooks/useCrewRole.ts new file mode 100644 index 0000000..9600633 --- /dev/null +++ b/src/hooks/useCrewRole.ts @@ -0,0 +1,71 @@ +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import type { SkateCrew } from '@/lib/types'; + +export type CrewRole = 'founder' | 'admin' | 'member' | 'none'; + +export interface CrewRoleInfo { + role: CrewRole; + isFounder: boolean; + /** Founder OR delegated admin — the set allowed elevated actions. */ + isAdmin: boolean; + isMember: boolean; + /** Can invite/vouch members & moderate the crew feed/chat. */ + canManage: boolean; + /** Can edit the crew's core identity (founder only — addressable event owner). */ + canEditProfile: boolean; +} + +/** + * Resolve the logged-in user's role within a crew. + * + * Governance model (see NIP.md): + * - **founder**: authored the crew's addressable event → sole owner of the + * crew identity. Only they can edit the profile (Nostr addressable events can + * only be overwritten by their author's key). + * - **admin**: a delegated, confirmed member the founder marked with an `admin` + * role. Admins act with their OWN keys to invite members and moderate the + * crew feed/chat, but cannot rewrite the profile. + * - **member**: a claimed member. + * - **none**: not part of the crew. + * + * Egalitarian shared-editing is intentionally unsupported: it would require a + * shared private key (insecure) or a consensus layer (non-interoperable). + */ +export function useCrewRole(crew: SkateCrew | null | undefined): CrewRoleInfo { + const { user } = useCurrentUser(); + const pubkey = user?.pubkey; + + if (!crew || !pubkey) { + return { + role: 'none', + isFounder: false, + isAdmin: false, + isMember: false, + canManage: false, + canEditProfile: false, + }; + } + + const isFounder = pubkey === crew.pubkey; + const isDelegatedAdmin = crew.admins.includes(pubkey); + const isMember = crew.members.includes(pubkey); + + const role: CrewRole = isFounder + ? 'founder' + : isDelegatedAdmin + ? 'admin' + : isMember + ? 'member' + : 'none'; + + const isAdmin = isFounder || isDelegatedAdmin; + + return { + role, + isFounder, + isAdmin, + isMember, + canManage: isAdmin, + canEditProfile: isFounder, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0b6c6f3..3dea6ac 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -85,6 +85,27 @@ export function parseMemberPubkeys(event: NostrEvent): string[] { .map(([, value]) => value.toLowerCase()); } +/** + * Extract the admin pubkeys a collective delegates powers to. + * + * Admins are ordinary members whose `p` tag carries an `"admin"` role marker in + * the 4th position: `["p", , , "admin"]`. Using a marker on + * the standard `p` tag keeps admins visible to any client that reads members, + * while letting SkateSpots grant them elevated *actions* (invite, moderate). + * + * Note: admins are delegated *powers*, not edit rights — only the founder (the + * event author) can overwrite the addressable crew profile. Admins act with + * their own keys. + */ +export function parseAdminPubkeys(event: NostrEvent): string[] { + return event.tags + .filter( + ([name, value, , marker]) => + name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin', + ) + .map(([, value]) => value.toLowerCase()); +} + /** All Lab provider kinds (The Lab service profiles). */ export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const; @@ -861,8 +882,10 @@ export interface SkateCrew { instagram?: string; social?: string; // First social link (backward compat) socials: string[]; // All additional social links (`social` tags) + nip05?: string; // Crew's NIP-05 identifier (verified against founder pubkey) noffer?: string; // Optional shared tip jar (CLINK) members: string[]; // Member pubkeys (hex) via `p` tags + admins: string[]; // Delegated admin pubkeys (subset of members, `admin` marker) spotRefs: string[]; // Home spots (kind 30818 refs) labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d) createdAt: number; @@ -1387,7 +1410,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { const social = socials[0]; const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1]; const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined; + const nip05 = clampStr(event.tags.find(([name]) => name === 'nip05')?.[1], 100); const members = parseMemberPubkeys(event); + // Admins must be a subset of claimed members. + const admins = parseAdminPubkeys(event).filter((pk) => members.includes(pk)); const spotRefs = event.tags .filter(([name, value]) => name === 'a' && value?.startsWith('30818:')) .map(([, value]) => value); @@ -1408,8 +1434,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null { instagram, social, socials, + nip05, noffer, members, + admins, spotRefs, labRefs, createdAt: event.created_at, diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 995d303..d4a2de9 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -11,20 +11,21 @@ import { ZapButton } from '@/components/ZapButton'; import { ZapCrewDialog } from '@/components/ZapCrewDialog'; import { ClinkPayButton } from '@/components/ClinkPayButton'; import { CollectiveMembers } from '@/components/CollectiveMembers'; +import { CrewNip05Badge } from '@/components/CrewNip05Badge'; import { CrewChat } from '@/components/CrewChat'; import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard'; import { LinkedLabCard } from '@/components/CrossLinkCards'; import { useCollectiveMembers } from '@/hooks/useCollectiveMembers'; +import { useCrewRole } from '@/hooks/useCrewRole'; import { NoteContent } from '@/components/NoteContent'; import { useCrew } from '@/hooks/useCrews'; import { useCrewFeed } from '@/hooks/useCrewFeed'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types'; import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl'; import NotFound from './NotFound'; -import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap } from 'lucide-react'; +import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; function FeedItem({ event }: { event: NostrEvent }) { @@ -59,8 +60,8 @@ function FeedItem({ event }: { event: NostrEvent }) { export default function CrewDetailPage() { const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>(); const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey); - const { user } = useCurrentUser(); const author = useAuthor(crew?.pubkey); + const { isFounder, isAdmin, role } = useCrewRole(crew); useSeoMeta({ title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots', @@ -109,7 +110,6 @@ export default function CrewDetailPage() { const npub = nip19.npubEncode(crew.pubkey); const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey); - const isFounder = user?.pubkey === crew.pubkey; const hasMembers = crew.members.length > 0; return ( @@ -153,6 +153,7 @@ export default function CrewDetailPage() { Crew +
{crew.location && ( @@ -167,13 +168,25 @@ export default function CrewDetailPage() { )}
- {isFounder && ( - - - - )} +
+ {isFounder && ( + + + + )} + {isAdmin && !isFounder && ( + + You're an admin + + )} + {role === 'member' && ( + + Member + + )} +
{crew.bio && ( @@ -309,6 +322,7 @@ export default function CrewDetailPage() { founderPubkey={crew.pubkey} identifier={crew.identifier} claimedMembers={crew.members} + admins={crew.admins} accentText="text-teal-400" accentBorder="border-teal-500/40" />