Redesign crew detail + crews page (Phase 2: visual)
- 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>
This commit is contained in:
parent
e8c1fbc71a
commit
8a384c79d2
4 changed files with 518 additions and 126 deletions
252
src/components/crew/CrewHero.tsx
Normal file
252
src/components/crew/CrewHero.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
Calendar,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
HardHat,
|
||||||
|
Camera,
|
||||||
|
GraduationCap,
|
||||||
|
Palette,
|
||||||
|
Music,
|
||||||
|
Dumbbell,
|
||||||
|
BadgeCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { CrewActivityPulse } from '@/components/CrewBadge';
|
||||||
|
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { crewAccent, crewBadge, type CrewActivity } from '@/lib/crewHelpers';
|
||||||
|
import { CREW_FOCUS_LABELS, CREW_VIBE_LABELS, type CrewFocus, type SkateCrew } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const FOCUS_ICON: Record<CrewFocus, typeof Users> = {
|
||||||
|
mixed: Sparkles,
|
||||||
|
builder: HardHat,
|
||||||
|
photo: Camera,
|
||||||
|
coaching: GraduationCap,
|
||||||
|
art: Palette,
|
||||||
|
music: Music,
|
||||||
|
wellness: Dumbbell,
|
||||||
|
};
|
||||||
|
|
||||||
|
const REP_STYLES: Record<string, { chip: string; glow: string }> = {
|
||||||
|
og: { chip: 'bg-amber-500/20 text-amber-200 border-amber-400/40', glow: 'shadow-amber-500/30' },
|
||||||
|
verified: { chip: 'bg-teal-500/20 text-teal-200 border-teal-400/40', glow: 'shadow-teal-500/30' },
|
||||||
|
established: { chip: 'bg-fuchsia-500/20 text-fuchsia-200 border-fuchsia-400/40', glow: 'shadow-fuchsia-500/30' },
|
||||||
|
crew: { chip: 'bg-white/10 text-gray-200 border-white/20', glow: 'shadow-white/10' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immersive, team-page-style hero for a crew.
|
||||||
|
*
|
||||||
|
* - Full-bleed banner from `crew.image` (or a generated accent gradient when
|
||||||
|
* none is set), overlaid with the crew's deterministic accent colour so no two
|
||||||
|
* crews look alike.
|
||||||
|
* - Big team name, reputation crown, live activity pulse, focus + verification.
|
||||||
|
* - The crew's logo sits over the banner like a real club crest.
|
||||||
|
*
|
||||||
|
* Actions (invite/edit/join) are rendered by the parent via `actions` so the
|
||||||
|
* hero stays purely presentational.
|
||||||
|
*/
|
||||||
|
export function CrewHero({
|
||||||
|
crew,
|
||||||
|
activity,
|
||||||
|
confirmedCount,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
crew: SkateCrew;
|
||||||
|
activity?: CrewActivity;
|
||||||
|
confirmedCount?: number;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const accent = crewAccent(crew);
|
||||||
|
const founder = useAuthor(crew.pubkey);
|
||||||
|
const founderName = founder.data?.metadata?.name ?? genUserName(crew.pubkey);
|
||||||
|
const founderPic = founder.data?.metadata?.picture;
|
||||||
|
const npub = nip19.npubEncode(crew.pubkey);
|
||||||
|
|
||||||
|
const rep = crewBadge(crew, confirmedCount);
|
||||||
|
const repStyle = REP_STYLES[rep.key] ?? REP_STYLES.crew;
|
||||||
|
const FocusIcon = FOCUS_ICON[crew.focus];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden rounded-3xl border border-white/10 bg-black">
|
||||||
|
{/* ── Banner ── */}
|
||||||
|
<div className="relative h-40 w-full sm:h-56">
|
||||||
|
{crew.image ? (
|
||||||
|
<img
|
||||||
|
src={crew.image}
|
||||||
|
alt={crew.name}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
style={{ filter: 'saturate(1.05)' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(120% 140% at 20% 0%, ${accent.hex}55 0%, ${accent.hex}18 40%, #000 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Accent wash + bottom fade so text is always legible */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to top, #000 6%, ${accent.hex}14 55%, transparent 100%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="absolute -left-16 -top-16 h-56 w-56 rounded-full blur-3xl opacity-40"
|
||||||
|
style={{ background: accent.hex }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-right floating rep + activity */}
|
||||||
|
<div className="absolute right-3 top-3 flex flex-wrap items-center justify-end gap-1.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-black uppercase tracking-wide backdrop-blur-md shadow-lg',
|
||||||
|
repStyle.chip,
|
||||||
|
repStyle.glow,
|
||||||
|
)}
|
||||||
|
title={rep.label}
|
||||||
|
>
|
||||||
|
<span className="leading-none">{rep.emoji}</span> {rep.label}
|
||||||
|
</span>
|
||||||
|
{activity && activity.level !== 'dormant' && (
|
||||||
|
<span className="inline-flex items-center rounded-full border border-white/10 bg-black/50 px-2.5 py-1 backdrop-blur-md">
|
||||||
|
<CrewActivityPulse activity={activity} showDormant />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Identity row (logo overlaps the banner) ── */}
|
||||||
|
<div className="relative px-4 pb-5 sm:px-6">
|
||||||
|
<div className="-mt-12 flex items-end gap-4 sm:-mt-14">
|
||||||
|
<div
|
||||||
|
className="relative shrink-0 rounded-3xl p-[3px] shadow-2xl"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${accent.hex}, ${accent.hex}44)` }}
|
||||||
|
>
|
||||||
|
<Avatar className="h-24 w-24 rounded-[calc(1.5rem-3px)] border-4 border-black sm:h-28 sm:w-28">
|
||||||
|
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
||||||
|
<AvatarFallback
|
||||||
|
className="rounded-[calc(1.5rem-3px)] text-3xl font-black"
|
||||||
|
style={{ color: accent.hex, background: `${accent.hex}1f` }}
|
||||||
|
>
|
||||||
|
{crew.name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
{/* member count crest */}
|
||||||
|
<span
|
||||||
|
className="absolute -bottom-2 -right-2 inline-flex items-center gap-1 rounded-full border-2 border-black px-2 py-0.5 text-xs font-black text-black shadow-lg"
|
||||||
|
style={{ background: accent.hex }}
|
||||||
|
>
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{crew.members.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Focus tag floats to the right on desktop */}
|
||||||
|
<div className="hidden flex-1 items-center justify-end pb-2 sm:flex">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-black uppercase tracking-wide"
|
||||||
|
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
|
||||||
|
>
|
||||||
|
<FocusIcon className="h-3.5 w-3.5" />
|
||||||
|
{CREW_FOCUS_LABELS[crew.focus]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name + meta */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<h1
|
||||||
|
className="text-3xl font-black uppercase leading-none text-white sm:text-4xl"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Impact, "Arial Black", sans-serif',
|
||||||
|
textShadow: `0 0 24px ${accent.hex}55`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crew.name}
|
||||||
|
</h1>
|
||||||
|
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-gray-400">
|
||||||
|
{crew.location && (
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<MapPin className="h-4 w-4 shrink-0" style={{ color: accent.hex }} />
|
||||||
|
<span className="truncate">{crew.location}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{crew.founded && (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-4 w-4 shrink-0" /> Est. {crew.founded}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Team leader inline */}
|
||||||
|
<Link
|
||||||
|
to={`/${npub}`}
|
||||||
|
className="group flex items-center gap-1.5 transition-colors hover:text-white"
|
||||||
|
title={`Team leader: ${founderName}`}
|
||||||
|
>
|
||||||
|
<Avatar className="h-5 w-5 border border-white/20">
|
||||||
|
<AvatarImage src={founderPic} alt={founderName} />
|
||||||
|
<AvatarFallback className="text-[8px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5" style={{ color: accent.hex }} />
|
||||||
|
Led by <span className="font-bold text-gray-200 group-hover:text-white">{founderName}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile focus tag */}
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-1.5 sm:hidden">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-black uppercase tracking-wide"
|
||||||
|
style={{ color: accent.hex, borderColor: `${accent.hex}55`, background: `${accent.hex}14` }}
|
||||||
|
>
|
||||||
|
<FocusIcon className="h-3 w-3" />
|
||||||
|
{CREW_FOCUS_LABELS[crew.focus]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vibes */}
|
||||||
|
{crew.vibes.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{crew.vibes.slice(0, 6).map((v) => (
|
||||||
|
<span
|
||||||
|
key={v}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.06] px-2.5 py-0.5 text-xs font-bold text-gray-300"
|
||||||
|
>
|
||||||
|
{CREW_VIBE_LABELS[v] || v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
{crew.bio && (
|
||||||
|
<p className="mt-4 max-w-2xl whitespace-pre-wrap break-words text-[15px] leading-relaxed text-gray-300">
|
||||||
|
{crew.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{actions && <div className="mt-5 flex flex-wrap items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny verified tick used elsewhere; re-exported so callers don't re-import lucide. */
|
||||||
|
export { BadgeCheck as CrewVerifiedTick };
|
||||||
163
src/components/crew/CrewLeadership.tsx
Normal file
163
src/components/crew/CrewLeadership.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { Crown, ShieldCheck, MessageSquare, Check } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { crewAccent } from '@/lib/crewHelpers';
|
||||||
|
import type { SkateCrew } from '@/lib/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single leader ("Team Leader" for the founder, "Captain" for admins).
|
||||||
|
* Big, personable, links to the profile + a quick message action.
|
||||||
|
*/
|
||||||
|
function LeaderCard({
|
||||||
|
pubkey,
|
||||||
|
role,
|
||||||
|
accentHex,
|
||||||
|
confirmed,
|
||||||
|
}: {
|
||||||
|
pubkey: string;
|
||||||
|
role: 'leader' | 'captain';
|
||||||
|
accentHex: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
}) {
|
||||||
|
const author = useAuthor(pubkey);
|
||||||
|
const meta = author.data?.metadata;
|
||||||
|
const name = meta?.name ?? genUserName(pubkey);
|
||||||
|
const pic = meta?.picture;
|
||||||
|
const npub = nip19.npubEncode(pubkey);
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const isSelf = user?.pubkey === pubkey;
|
||||||
|
|
||||||
|
const isLeader = role === 'leader';
|
||||||
|
const RoleIcon = isLeader ? Crown : ShieldCheck;
|
||||||
|
const roleLabel = isLeader ? 'Team Leader' : 'Captain';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative flex items-center gap-3 overflow-hidden rounded-2xl border p-3 transition-all',
|
||||||
|
isLeader ? 'border-transparent' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]',
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isLeader
|
||||||
|
? {
|
||||||
|
background: `linear-gradient(135deg, ${accentHex}1f, transparent 70%)`,
|
||||||
|
borderColor: `${accentHex}55`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Link to={`/${npub}`} className="relative shrink-0">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-[2px]"
|
||||||
|
style={{ background: isLeader ? `linear-gradient(135deg, ${accentHex}, ${accentHex}55)` : 'transparent' }}
|
||||||
|
>
|
||||||
|
<Avatar className={cn('rounded-[calc(1rem-2px)]', isLeader ? 'h-14 w-14' : 'h-11 w-11')}>
|
||||||
|
<AvatarImage src={pic} alt={name} className="object-cover" />
|
||||||
|
<AvatarFallback
|
||||||
|
className="rounded-[calc(1rem-2px)] font-black"
|
||||||
|
style={{ color: accentHex, background: `${accentHex}1f` }}
|
||||||
|
>
|
||||||
|
{name.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-wider"
|
||||||
|
style={{ color: accentHex, background: `${accentHex}1f` }}
|
||||||
|
>
|
||||||
|
<RoleIcon className="h-3 w-3" />
|
||||||
|
{roleLabel}
|
||||||
|
</span>
|
||||||
|
<Link to={`/${npub}`} className="mt-1 block">
|
||||||
|
<p className={cn('truncate font-black text-white', isLeader ? 'text-lg' : 'text-sm')}>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
{confirmed && !isLeader && (
|
||||||
|
<p className="flex items-center gap-1 text-[11px] font-bold text-green-500">
|
||||||
|
<Check className="h-3 w-3" /> Verified member
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick DM (not for yourself) */}
|
||||||
|
{!isSelf && (
|
||||||
|
<Link
|
||||||
|
to={`/messages/${npub}`}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-white/10 text-gray-400 transition-colors hover:border-white/20 hover:text-white"
|
||||||
|
title={`Message ${name}`}
|
||||||
|
>
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "The Leadership" — the human hierarchy of a crew.
|
||||||
|
*
|
||||||
|
* - **Team Leader**: the founder (author of the crew event). The one who
|
||||||
|
* ultimately controls the roster on Nostr.
|
||||||
|
* - **Captains**: delegated admins who help run the crew (invite, moderate).
|
||||||
|
*
|
||||||
|
* This makes the crew read like a real team, and lays the groundwork for
|
||||||
|
* dedicated coordinator roles (MLS / local coordinator) that will sync with
|
||||||
|
* Lab profiles.
|
||||||
|
*/
|
||||||
|
export function CrewLeadership({
|
||||||
|
crew,
|
||||||
|
confirmedAdmins,
|
||||||
|
}: {
|
||||||
|
crew: SkateCrew;
|
||||||
|
/** Admin pubkeys whose membership is confirmed (for the verified tick). */
|
||||||
|
confirmedAdmins?: Set<string>;
|
||||||
|
}) {
|
||||||
|
const accent = crewAccent(crew);
|
||||||
|
// Admins are always a subset of members; founder is never listed as an admin card.
|
||||||
|
const captains = crew.admins.filter((pk) => pk !== crew.pubkey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-2 bg-black/90" style={{ borderColor: `${accent.hex}40` }}>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||||
|
<Crown className="h-5 w-5" style={{ color: accent.hex }} />
|
||||||
|
Leadership
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2.5">
|
||||||
|
<LeaderCard pubkey={crew.pubkey} role="leader" accentHex={accent.hex} />
|
||||||
|
|
||||||
|
{captains.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="px-1 pt-1 text-[11px] font-black uppercase tracking-widest text-gray-500">
|
||||||
|
{captains.length} Captain{captains.length === 1 ? '' : 's'}
|
||||||
|
</p>
|
||||||
|
{captains.map((pk) => (
|
||||||
|
<LeaderCard
|
||||||
|
key={pk}
|
||||||
|
pubkey={pk}
|
||||||
|
role="captain"
|
||||||
|
accentHex={accent.hex}
|
||||||
|
confirmed={confirmedAdmins?.has(pk)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="pt-1 text-[11px] leading-relaxed text-gray-500">
|
||||||
|
The Team Leader runs the roster; Captains help invite, host jams and keep the crew tidy.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,13 +12,14 @@ import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
import { ZapCrewDialog } from '@/components/ZapCrewDialog';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
import { CrewNip05Badge } from '@/components/CrewNip05Badge';
|
import { CrewHero } from '@/components/crew/CrewHero';
|
||||||
|
import { CrewLeadership } from '@/components/crew/CrewLeadership';
|
||||||
|
import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity';
|
||||||
import { CrewChat } from '@/components/CrewChat';
|
import { CrewChat } from '@/components/CrewChat';
|
||||||
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
import { CrewAgentPanel } from '@/components/CrewAgentPanel';
|
||||||
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
import { CrewTreasuryCard } from '@/components/CrewTreasuryCard';
|
||||||
import { CrewLookingForCard } from '@/components/CrewLookingFor';
|
import { CrewLookingForCard } from '@/components/CrewLookingFor';
|
||||||
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
|
import { CrewRequestToJoin } from '@/components/CrewRequestToJoin';
|
||||||
import { CrewRepBadge } from '@/components/CrewBadge';
|
|
||||||
import { useCrewEvents } from '@/hooks/useCrewEvents';
|
import { useCrewEvents } from '@/hooks/useCrewEvents';
|
||||||
import { EventCard } from '@/components/EventCard';
|
import { EventCard } from '@/components/EventCard';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
|
|
@ -48,7 +49,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, CREW_VIBE_LABELS, SHOW_CREW_TREASURY } from '@/lib/types';
|
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SHOW_CREW_TREASURY } 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';
|
||||||
|
|
@ -190,6 +191,17 @@ export default function CrewDetailPage() {
|
||||||
]))
|
]))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
|
|
@ -236,107 +248,52 @@ export default function CrewDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Immersive team-page hero */}
|
||||||
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden">
|
<CrewHero
|
||||||
<CardContent className="pt-6">
|
crew={crew}
|
||||||
<div className="mb-4">
|
activity={activity}
|
||||||
{/* Top: avatar + title/meta. Actions move below on mobile so the
|
confirmedCount={confirmedCount}
|
||||||
title never gets squeezed by the button column. */}
|
actions={
|
||||||
<div className="flex items-start gap-3 sm:gap-4">
|
(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') ? (
|
||||||
<Avatar className="h-14 w-14 sm:h-20 sm:w-20 rounded-2xl border-2 border-teal-500/40 flex-shrink-0">
|
<>
|
||||||
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
{canManage && <CrewInviteDialog crew={crew} isFounder={isFounder} />}
|
||||||
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-lg sm:text-xl">
|
{isFounder && (
|
||||||
{crew.name.slice(0, 2).toUpperCase()}
|
<>
|
||||||
</AvatarFallback>
|
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||||
</Avatar>
|
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||||
<div className="flex-1 min-w-0">
|
<Edit className="h-3.5 w-3.5" /> Edit
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
</Button>
|
||||||
<h1 className="text-2xl sm:text-3xl font-black uppercase text-teal-400 break-words" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
</Link>
|
||||||
{crew.name}
|
<DeleteEntityDialog
|
||||||
</h1>
|
event={crew.event}
|
||||||
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
|
entityLabel="crew"
|
||||||
<Users className="h-3 w-3" /> Crew
|
entityName={crew.name}
|
||||||
</Badge>
|
redirectTo="/crews"
|
||||||
<CrewNip05Badge nip05={crew.nip05} founderPubkey={crew.pubkey} />
|
triggerText="Disband"
|
||||||
<CrewRepBadge crew={crew} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
|
|
||||||
{crew.location && (
|
|
||||||
<span className="flex items-center gap-1 min-w-0">
|
|
||||||
<MapPin className="h-4 w-4 text-teal-400 flex-shrink-0" />
|
|
||||||
<span className="truncate">{crew.location}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{crew.founded && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-4 w-4 flex-shrink-0" /> Since {crew.founded}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions: full-width wrap under the header, comfortable tap targets */}
|
|
||||||
{(canManage || isFounder || (isAdmin && !isFounder) || role === 'member' || role === 'none') && (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
{canManage && (
|
|
||||||
<CrewInviteDialog
|
|
||||||
crew={crew}
|
|
||||||
isFounder={isFounder}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</>
|
||||||
{isFounder && (
|
)}
|
||||||
<>
|
{isAdmin && !isFounder && (
|
||||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
<Badge className="gap-1 bg-teal-500/20 text-teal-300 border-0">
|
||||||
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
<ShieldCheck className="h-3 w-3" /> You're a captain
|
||||||
<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 an admin
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{crew.bio && (
|
|
||||||
<p className="text-gray-300 whitespace-pre-wrap mb-4">{crew.bio}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{crew.vibes.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{crew.vibes.map((v) => (
|
|
||||||
<Badge key={v} className="bg-teal-500/15 text-teal-300 border-teal-500/30">
|
|
||||||
{CREW_VIBE_LABELS[v] || v}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
)}
|
||||||
</div>
|
{role === 'member' && (
|
||||||
)}
|
<Badge variant="outline" className="gap-1 border-teal-500/30 text-teal-400">
|
||||||
</CardContent>
|
<Users className="h-3 w-3" /> Member
|
||||||
</Card>
|
</Badge>
|
||||||
|
)}
|
||||||
|
{role === 'none' && (
|
||||||
|
<CrewRequestToJoin
|
||||||
|
crewName={crew.name}
|
||||||
|
founderPubkey={crew.pubkey}
|
||||||
|
admins={crew.admins}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
{/* Open call — looking for filmers/editors/members (Lab pipeline) */}
|
||||||
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
<CrewLookingForCard lookingFor={crew.lookingFor} />
|
||||||
|
|
@ -418,8 +375,11 @@ export default function CrewDetailPage() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar — ordered: 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) */}
|
||||||
|
<CrewLeadership crew={crew} confirmedAdmins={confirmedAdmins} />
|
||||||
|
|
||||||
{/* Members (with per-member visibility respected) */}
|
{/* Members (with per-member visibility respected) */}
|
||||||
{hasMembers && (
|
{hasMembers && (
|
||||||
<CollectiveMembers
|
<CollectiveMembers
|
||||||
|
|
|
||||||
|
|
@ -197,31 +197,48 @@ export default function CrewsPage() {
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="container max-w-3xl px-4 py-6">
|
<div className="container max-w-3xl px-4 py-6">
|
||||||
{/* Header */}
|
{/* Hero header band */}
|
||||||
<div className="mb-4 flex items-start justify-between gap-3">
|
<div className="relative mb-5 overflow-hidden rounded-3xl border border-teal-500/20 bg-gradient-to-br from-teal-500/[0.12] via-black to-black p-5 sm:p-6">
|
||||||
<div className="min-w-0">
|
<div
|
||||||
<h1
|
aria-hidden
|
||||||
className="flex items-center gap-2 text-2xl font-black uppercase text-teal-400 sm:text-3xl"
|
className="pointer-events-none absolute -right-16 -top-20 h-56 w-56 rounded-full bg-teal-500/25 blur-3xl"
|
||||||
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
/>
|
||||||
>
|
<div className="relative flex items-start justify-between gap-3">
|
||||||
<Users className="h-6 w-6 shrink-0 sm:h-7 sm:w-7" /> Crews
|
<div className="min-w-0">
|
||||||
</h1>
|
<h1
|
||||||
<p className="mt-1 text-sm text-gray-400">Your map-anchored skate family — the people you skate <span className="text-teal-400/80 font-semibold">with</span>, not who you hire (that's The Lab).</p>
|
className="flex items-center gap-2 text-3xl font-black uppercase leading-none text-white sm:text-4xl"
|
||||||
{/* Social-proof stat line */}
|
style={{ fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 24px rgba(20,184,166,0.4)' }}
|
||||||
{!isLoading && crews && crews.length > 0 && (
|
>
|
||||||
<p className="mt-1 text-xs font-bold text-teal-500/80">
|
<Users className="h-7 w-7 shrink-0 text-teal-400 sm:h-8 sm:w-8" /> Crews
|
||||||
{crews.length} crew{crews.length === 1 ? '' : 's'}
|
</h1>
|
||||||
{cityCount > 0 && ` · ${cityCount} ${cityCount === 1 ? 'city' : 'cities'}`}
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-gray-400">
|
||||||
|
Your map-anchored skate family — the people you skate{' '}
|
||||||
|
<span className="font-semibold text-teal-400/90">with</span>, not who you hire (that's The Lab).
|
||||||
</p>
|
</p>
|
||||||
)}
|
{/* Social-proof stat line */}
|
||||||
|
{!isLoading && crews && crews.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/30 bg-teal-500/10 px-3 py-1 text-xs font-black text-teal-300">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{crews.length} crew{crews.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{cityCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-bold text-gray-300">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-teal-400" />
|
||||||
|
{cityCount} {cityCount === 1 ? 'city' : 'cities'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/create-crew" className="shrink-0">
|
||||||
|
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black shadow-lg shadow-teal-500/30 hover:bg-teal-600">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Create Crew</span>
|
||||||
|
<span className="sm:hidden">Create</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/create-crew" className="shrink-0">
|
|
||||||
<Button className="gap-2 bg-teal-500 text-sm font-black uppercase text-black hover:bg-teal-600">
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
<span className="hidden sm:inline">Create Crew</span>
|
|
||||||
<span className="sm:hidden">Create</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue