- New CrewHero: immersive full-bleed banner with per-crew accent glow, club-crest logo overlapping the banner, member-count crest, big team name with neon shadow, reputation crown + live activity pulse, focus/verification chips, "Led by" team-leader inline, vibes and bio. Actions are passed in from the page. - New CrewLeadership card: frames the founder as "Team Leader" and admins as "Captains" with role crests, verified ticks and quick-DM — establishing the crew hierarchy and laying groundwork for coordinator roles. - Rewire CrewDetailPage to use CrewHero + CrewLeadership; wire live activity via useCrewsActivity and confirmed-member/admin sets. Removed the old flat hero card and now-unused imports. - Upgrade CrewsPage header into a proper hero band with gradient, glow and stat pills. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
591 lines
27 KiB
TypeScript
591 lines
27 KiB
TypeScript
import { useState } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { nip19 } from 'nostr-tools';
|
|
import { Layout } from '@/components/Layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
|
import { CrewHero } from '@/components/crew/CrewHero';
|
|
import { CrewLeadership } from '@/components/crew/CrewLeadership';
|
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
|
import { CrewChat } from '@/components/CrewChat';
|
|
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
|
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
|
import { CrewLookingForCard } from '@/components/CrewLookingFor';
|
|
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
|
|
import { useCrewEvents } from '@/hooks/useCrewEvents';
|
|
import { EventCard } from '@/components/EventCard';
|
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
|
import { MemberVisibilityControl } from '@/components/MemberVisibilityControl';
|
|
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
AlertDialogTrigger,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { useCollectiveMembers } from '@/hooks/useCollectiveMembers';
|
|
import { useCrewRole } from '@/hooks/useCrewRole';
|
|
import { useCrewModeration, useHideCrewPost } from '@/hooks/useCrewModeration';
|
|
import { CrewInviteDialog } from '@/components/CrewInviteDialog';
|
|
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { NoteContent } from '@/components/NoteContent';
|
|
import { useCrew } from '@/hooks/useCrews';
|
|
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 { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
|
import { cn } from '@/lib/utils';
|
|
import NotFound from './NotFound';
|
|
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, EyeOff, Music } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
function FeedItem({
|
|
event,
|
|
canModerate,
|
|
onHide,
|
|
isHiding,
|
|
}: {
|
|
event: NostrEvent;
|
|
canModerate?: boolean;
|
|
onHide?: (event: NostrEvent) => void;
|
|
isHiding?: boolean;
|
|
}) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const name = metadata?.name ?? genUserName(event.pubkey);
|
|
const npub = nip19.npubEncode(event.pubkey);
|
|
|
|
return (
|
|
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
|
|
<div className="mb-1.5 flex items-center gap-2">
|
|
<Link to={`/${npub}`}>
|
|
<Avatar className="h-7 w-7">
|
|
<AvatarImage src={metadata?.picture} alt={name} />
|
|
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
</Link>
|
|
<Link to={`/${npub}`} className="text-sm font-bold text-gray-200 hover:text-white truncate">
|
|
{name}
|
|
</Link>
|
|
<span className="ml-auto text-[11px] text-gray-500">
|
|
{new Date(event.created_at * 1000).toLocaleDateString()}
|
|
</span>
|
|
{canModerate && onHide && (
|
|
<AlertDialog>
|
|
<AlertDialogTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 shrink-0 text-gray-500 hover:text-red-400"
|
|
disabled={isHiding}
|
|
aria-label="Hide this post"
|
|
title="Hide from crew feed (admin)"
|
|
>
|
|
<EyeOff className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</AlertDialogTrigger>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Hide this post?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
As a crew moderator you can hide this post from the crew feed for everyone.
|
|
The original note stays on Nostr — this only removes it from this crew's feed.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => onHide(event)}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
Hide post
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
|
|
<NoteContent event={event} className="text-sm" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CrewDetailPage() {
|
|
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
|
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
|
|
const author = useAuthor(crew?.pubkey);
|
|
const { user } = useCurrentUser();
|
|
const { isFounder, isAdmin, role, canManage } = useCrewRole(crew);
|
|
const { toast } = useToast();
|
|
const [agentOpen, setAgentOpen] = useState(false);
|
|
|
|
useSeoMeta({
|
|
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
|
description: crew?.bio || 'A skate crew on SkateSpots.',
|
|
});
|
|
|
|
// Feed = founder + listed members
|
|
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
|
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
|
|
|
// Crew-hosted events (jams, comps, meetups).
|
|
const { data: crewEvents } = useCrewEvents(crew?.pubkey, crew?.identifier);
|
|
|
|
// Moderation — founder + admins can hide feed posts (NIP-32 labels).
|
|
const crewCoord = crew ? `${SKATE_CREW_KIND}:${crew.pubkey}:${crew.identifier}` : '';
|
|
const moderators = crew ? Array.from(new Set([crew.pubkey, ...crew.admins])) : [];
|
|
const { data: hiddenIds } = useCrewModeration({
|
|
crewCoord,
|
|
moderators,
|
|
enabled: !!crew,
|
|
});
|
|
const { mutateAsync: hidePost, isPending: isHiding } = useHideCrewPost(crewCoord);
|
|
|
|
const handleHidePost = async (ev: NostrEvent) => {
|
|
try {
|
|
await hidePost({ postId: ev.id, postAuthor: ev.pubkey });
|
|
toast({ title: 'Post hidden', description: 'Removed from the crew feed for everyone.' });
|
|
} catch (err) {
|
|
toast({
|
|
title: 'Failed to hide post',
|
|
description: err instanceof Error ? err.message : 'Please try again.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
// Confirmed members (for "Zap the Crew" — only pay people who vouched in)
|
|
const { data: memberStatuses } = useCollectiveMembers({
|
|
kind: SKATE_CREW_KIND,
|
|
founderPubkey: crew?.pubkey ?? '',
|
|
identifier: crew?.identifier ?? '',
|
|
claimedMembers: crew?.members ?? [],
|
|
enabled: !!crew && (crew.members.length ?? 0) > 0,
|
|
});
|
|
|
|
// Recipients: founder + confirmed members (dedup, founder always included)
|
|
const crewZapRecipients = crew
|
|
? Array.from(new Set([
|
|
crew.pubkey,
|
|
...(memberStatuses ?? []).filter((m) => m.status === 'confirmed').map((m) => m.pubkey),
|
|
]))
|
|
: [];
|
|
|
|
// Confirmed count + confirmed-admin set (fuel the hero's reputation crown and
|
|
// the leadership "verified" ticks).
|
|
const confirmedCount = (memberStatuses ?? []).filter((m) => m.status === 'confirmed').length;
|
|
const confirmedAdmins = new Set(
|
|
(memberStatuses ?? []).filter((m) => m.status === 'confirmed').map((m) => m.pubkey),
|
|
);
|
|
|
|
// Live activity for the hero pulse (reuse the batched hook with a single crew).
|
|
const { data: activityMap } = useCrewsActivity(crew ? [crew] : undefined);
|
|
const activity = crew ? activityMap?.get(crewCoordinate(crew)) : undefined;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8 max-w-4xl">
|
|
<Skeleton className="h-6 w-32 mb-6" />
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
<div className="md:col-span-2 space-y-6">
|
|
<Skeleton className="h-48 w-full" />
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
if (error || !crew) return <NotFound />;
|
|
|
|
const npub = nip19.npubEncode(crew.pubkey);
|
|
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
|
|
const hasMembers = crew.members.length > 0;
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8 max-w-4xl">
|
|
<Link to="/crews" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Crews
|
|
</Link>
|
|
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
{/* Main */}
|
|
<div className="md:col-span-2 space-y-6">
|
|
{/* Confirm membership prompt (listed pending members only) */}
|
|
{hasMembers && (
|
|
<ConfirmMembershipCard
|
|
kind={SKATE_CREW_KIND}
|
|
founderPubkey={crew.pubkey}
|
|
identifier={crew.identifier}
|
|
collectiveName={crew.name}
|
|
claimedMembers={crew.members}
|
|
accentBg="bg-teal-500 hover:bg-teal-600 text-black"
|
|
/>
|
|
)}
|
|
|
|
{/* Immersive team-page hero */}
|
|
<CrewHero
|
|
crew={crew}
|
|
activity={activity}
|
|
confirmedCount={confirmedCount}
|
|
actions={
|
|
(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? (
|
|
<>
|
|
{canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />}
|
|
{isFounder && (
|
|
<>
|
|
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
|
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
|
<Edit className="h-3.5 w-3.5" /> Edit
|
|
</Button>
|
|
</Link>
|
|
<DeleteEntityDialog
|
|
event={crew.event}
|
|
entityLabel="crew"
|
|
entityName={crew.name}
|
|
redirectTo="/crews"
|
|
triggerText="Disband"
|
|
/>
|
|
</>
|
|
)}
|
|
{isAdmin && !isFounder && (
|
|
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
|
|
<ShieldCheck className="h-3 w-3" /> You're a captain
|
|
</Badge>
|
|
)}
|
|
{role === 'member' && (
|
|
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
|
|
<Users className="h-3 w-3" /> Member
|
|
</Badge>
|
|
)}
|
|
{role === 'none' && (
|
|
<CrewRequestToJoin
|
|
crewName={crew.name}
|
|
founderPubkey={crew.pubkey}
|
|
admins={crew.admins}
|
|
/>
|
|
)}
|
|
</>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
|
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
|
|
|
{/* Crew treasury — hidden for now (kept in code, re-enable by flipping SHOW_CREW_TREASURY) */}
|
|
{SHOW_CREW_TREASURY && crew.goalRef && <CrewTreasuryCard goalId={crew.goalRef} />}
|
|
|
|
{/* Crew Agent — AI guide, shown when the crew enabled it */}
|
|
{crew.agentEnabled && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setAgentOpen(true)}
|
|
className="group flex w-full items-center gap-3 rounded-2xl border-2 border-teal-500/30 bg-gradient-to-br from-teal-500/10 to-transparent p-4 text-left transition-all hover:border-teal-500/60 hover:from-teal-500/15 active:scale-[0.99]"
|
|
>
|
|
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-teal-500/15 text-2xl">
|
|
🐝
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="flex items-center gap-2 font-black text-white">
|
|
Ask Sasquatch
|
|
<span className="rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase text-teal-300">
|
|
Crew Agent
|
|
</span>
|
|
</p>
|
|
<p className="text-xs text-gray-400">
|
|
Your crew's AI guide — nearby spots, events, coaches & gear, with live data.
|
|
</p>
|
|
</div>
|
|
<MessageSquare className="h-5 w-5 flex-shrink-0 text-teal-400/60 group-hover:text-teal-400" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Activity — one clean tabbed surface instead of three stacked cards */}
|
|
<Tabs defaultValue="events" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-2 bg-black/60 border border-white/10">
|
|
<TabsTrigger value="events" className="gap-1.5 data-[state=active]:bg-[#ccff00]/20 data-[state=active]:text-[#ccff00]">
|
|
<Calendar className="h-4 w-4" /> Events
|
|
{crewEvents && crewEvents.length > 0 && (
|
|
<span className="ml-0.5 rounded-full bg-white/10 px-1.5 text-[10px]">{crewEvents.length}</span>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="chat" className="gap-1.5 data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
|
|
<MessageSquare className="h-4 w-4" /> Chat
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Events */}
|
|
<TabsContent value="events" className="mt-4 space-y-3">
|
|
{canManage && (
|
|
<Link to={`/create-event?crew=${crew.pubkey}:${crew.identifier}`} className="block">
|
|
<Button size="sm" className="w-full gap-1.5 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90">
|
|
<Calendar className="h-3.5 w-3.5" /> Host a jam
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
{crewEvents && crewEvents.length > 0 ? (
|
|
crewEvents.slice(0, 8).map((ev) => <EventCard key={ev.id} event={ev} compact />)
|
|
) : (
|
|
<Card className="border-dashed border-white/10 bg-black/40">
|
|
<CardContent className="py-10 text-center text-sm text-gray-500">
|
|
No crew events yet. Host a jam, comp or meetup as the crew — it connects your
|
|
crew to the events calendar and lets people zap it.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Chat */}
|
|
<TabsContent value="chat" className="mt-4">
|
|
<CrewChat
|
|
crewName={crew.name}
|
|
founderPubkey={crew.pubkey}
|
|
members={crew.members}
|
|
admins={crew.admins}
|
|
crewCoord={crewCoord}
|
|
canModerate={canManage}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* Sidebar — ordered: leadership → who → support → band → connections → founder */}
|
|
<div className="space-y-6">
|
|
{/* Leadership — team leader (founder) + captains (admins) */}
|
|
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
|
|
|
|
{/* Members (with per-member visibility respected) */}
|
|
{hasMembers && (
|
|
<CollectiveMembers
|
|
kind={SKATE_CREW_KIND}
|
|
founderPubkey={crew.pubkey}
|
|
identifier={crew.identifier}
|
|
claimedMembers={crew.members}
|
|
admins={crew.admins}
|
|
accentText="text-teal-400"
|
|
accentBorder="border-teal-500/40"
|
|
/>
|
|
)}
|
|
|
|
{/* A confirmed member can change their own visibility here */}
|
|
{hasMembers && (
|
|
<MemberVisibilityControl
|
|
kind={SKATE_CREW_KIND}
|
|
founderPubkey={crew.pubkey}
|
|
identifier={crew.identifier}
|
|
collectiveName={crew.name}
|
|
claimedMembers={crew.members}
|
|
accentBg="bg-teal-500 hover:bg-teal-600"
|
|
/>
|
|
)}
|
|
|
|
{/* Support the crew */}
|
|
<Card className="bg-black/90 border-2 border-teal-500/40">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
|
|
<Zap className="h-5 w-5" /> Support
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{/* Zap the whole crew — split across founder + confirmed members */}
|
|
{crewZapRecipients.length > 1 && (
|
|
<ZapCrewDialog crewName={crew.name} recipients={crewZapRecipients}>
|
|
<Button className="w-full justify-center gap-2 bg-teal-500 hover:bg-teal-600 font-bold">
|
|
<Users className="h-4 w-4" />
|
|
<Zap className="h-4 w-4" />
|
|
Zap the Crew
|
|
</Button>
|
|
</ZapCrewDialog>
|
|
)}
|
|
<ZapButton target={crew.event} className="w-full justify-center" />
|
|
{crew.noffer && (
|
|
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Crew Band — many crews double as a local band. Shown as its own card,
|
|
with a "Book for a gig" action that deep-links to create a Concert
|
|
event hosted by this crew (connects bands → the Events calendar). */}
|
|
{crew.bandName && (
|
|
<Card className="bg-gradient-to-br from-purple-500/10 to-black/90 border-2 border-purple-500/40">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg text-purple-300 uppercase font-black flex items-center gap-2">
|
|
<Music className="h-5 w-5 text-purple-400" /> Crew Band
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-purple-500/20 text-2xl">
|
|
🎸
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-base font-black text-white">{crew.bandName}</p>
|
|
<p className="text-xs text-purple-300/70">Live music · skate & sound</p>
|
|
</div>
|
|
</div>
|
|
|
|
{crew.bandUrl && sanitizeUrl(crew.bandUrl) && (
|
|
<a
|
|
href={sanitizeUrl(crew.bandUrl)!}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center justify-center gap-2 rounded-lg border border-purple-500/30 bg-purple-500/10 px-3 py-2 text-sm font-bold text-purple-200 transition-colors hover:bg-purple-500/20"
|
|
title={`Listen: ${urlDomain(crew.bandUrl)}`}
|
|
>
|
|
<Music className="h-4 w-4" /> Listen to the tunes
|
|
</a>
|
|
)}
|
|
|
|
<Link
|
|
to={`/create-event?crew=${crew.pubkey}:${crew.identifier}&type=concert&title=${encodeURIComponent(`${crew.bandName} live`)}`}
|
|
className="flex items-center justify-center gap-2 rounded-lg bg-purple-500 px-3 py-2 text-sm font-black uppercase text-white transition-colors hover:bg-purple-600"
|
|
>
|
|
<Calendar className="h-4 w-4" /> Set up a gig
|
|
</Link>
|
|
{user && user.pubkey !== crew.pubkey && (
|
|
<Link
|
|
to={`/messages/${npub}?message=${encodeURIComponent(`Hey ${crew.name} — interested in booking ${crew.bandName} for a gig. Are you around to talk dates?`)}`}
|
|
className="flex items-center justify-center gap-2 rounded-lg border border-purple-500/30 bg-purple-500/10 px-3 py-2 text-sm font-bold text-purple-200 transition-colors hover:bg-purple-500/20"
|
|
>
|
|
<MessageSquare className="h-4 w-4" /> Message about a gig
|
|
</Link>
|
|
)}
|
|
<p className="text-center text-[11px] leading-snug text-gray-500">
|
|
<strong className="text-gray-400">Set up a gig</strong> adds a music event to the
|
|
calendar hosted by this crew. <strong className="text-gray-400">Message</strong> opens
|
|
an encrypted DM to the crew to sort dates & details.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Linked Lab profiles — hire this crew */}
|
|
<LinkedLabCard labRefs={crew.labRefs} />
|
|
|
|
{/* Home spots */}
|
|
{crew.spotRefs.length > 0 && (
|
|
<Card className="bg-black/90 border-2 border-white/5">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
|
|
<MapPin className="h-5 w-5 text-teal-400" /> Home Spots
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{crew.spotRefs.map((ref) => {
|
|
const [, spotPubkey, spotD] = ref.split(':');
|
|
return (
|
|
<Link
|
|
key={ref}
|
|
to={`/spot/${spotPubkey}/${spotD}`}
|
|
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
|
|
>
|
|
<span className="text-base">🛹</span>
|
|
<span className="truncate">View spot</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
<Link to="/" className="block text-center text-xs text-teal-400 hover:text-teal-300 pt-1">
|
|
See on the map →
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Founder & links (bottom — provenance / external) */}
|
|
<Card className="bg-black/90 border border-white/10">
|
|
<CardHeader>
|
|
<CardTitle className="text-sm text-gray-400 uppercase font-black tracking-widest">
|
|
Founder & Links
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Avatar className="h-6 w-6">
|
|
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
|
|
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
</Avatar>
|
|
<span className="truncate">Started by {founderName}</span>
|
|
</Link>
|
|
|
|
{sanitizeUrl(crew.website) && (
|
|
<a href={sanitizeUrl(crew.website)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(crew.website!)}`}>
|
|
<Globe className="h-4 w-4 text-teal-400" />
|
|
<span className="truncate">{urlDomain(crew.website!)}</span>
|
|
</a>
|
|
)}
|
|
|
|
{crew.instagram && (
|
|
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Instagram className="h-4 w-4 text-teal-400" />
|
|
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
|
|
</a>
|
|
)}
|
|
|
|
{crew.socials.map((s, i) => {
|
|
const url = sanitizeUrl(s);
|
|
return url ? (
|
|
<a
|
|
key={`${s}-${i}`}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
|
|
title={`Opens: ${urlDomain(s)}`}
|
|
>
|
|
<Globe className="h-4 w-4 text-teal-400" />
|
|
<span className="truncate">{urlDomain(s)}</span>
|
|
</a>
|
|
) : (
|
|
<div
|
|
key={`${s}-${i}`}
|
|
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded"
|
|
>
|
|
<Globe className="h-4 w-4 text-teal-400" />
|
|
<span className="truncate">{s}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Crew Agent chat panel */}
|
|
{crew.agentEnabled && (
|
|
<CrewAgentPanel
|
|
open={agentOpen}
|
|
onOpenChange={setAgentOpen}
|
|
ctx={{ crewName: crew.name, location: crew.location, city: crew.location }}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
}
|