Interconnect Music ↔ Events: featured musician on events + gig list on musician profiles; count new Lab crafts in growth stats; document visibility/vouch/gig conventions in NIP.md
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
926470a86e
commit
96f7c3192d
7 changed files with 273 additions and 12 deletions
73
NIP.md
73
NIP.md
|
|
@ -1579,6 +1579,53 @@ attestations. A member is:
|
|||
Removing a member: the founder drops the `p` tag, or the member deletes their
|
||||
label event (NIP-09). Either direction flips the member out of "confirmed".
|
||||
|
||||
#### Member visibility (opt-in privacy)
|
||||
|
||||
A member controls their own discovery visibility **on their own attestation**
|
||||
(not the founder's event), via an optional `visibility` tag:
|
||||
|
||||
```json
|
||||
["visibility", "public" | "crew"]
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `public` *(default, tag absent)* | Listed everywhere — public crew page, search, discovery. |
|
||||
| `crew` | Hidden from non-members. To outsiders the member renders as an anonymous "hidden member" slot (`+N hidden`); to the founder and confirmed crewmates they render normally. |
|
||||
|
||||
This is **discovery privacy** at the app layer, controlled by the member's own
|
||||
key — so a public crew can contain a fully-hidden member. Note that the `p` tag
|
||||
on the founder's event is public regardless; `visibility` governs how clients
|
||||
*display* the member, not whether the pubkey exists on relays. Clients determine
|
||||
"is the viewer a crewmate?" by checking whether the viewer is the founder or a
|
||||
confirmed member before revealing `crew`-scoped members.
|
||||
|
||||
### Scene vouches (NIP-32 web-of-trust anti-fraud)
|
||||
|
||||
Any user can **vouch** for a Lab profile — an independent, signed attestation
|
||||
that the provider is legit. This is a lightweight web-of-trust layer to fight
|
||||
impersonation and fraud without gatekeeping. A vouch is a kind `1985` label
|
||||
signed with the voucher's own key, referencing the provider's coordinate:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "Filmed a full part with them, 100% legit.",
|
||||
"tags": [
|
||||
["L", "org.skatespots.vouch"],
|
||||
["l", "vouch", "org.skatespots.vouch"],
|
||||
["a", "35011:<providerPubkey>:<d-tag>"],
|
||||
["p", "<providerPubkey>"],
|
||||
["alt", "Scene vouch for a SkateSpots Lab provider"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients aggregate vouches for a coordinate and **trust-weight** them using each
|
||||
voucher's web-of-trust score, so a handful of vouches from trusted skaters
|
||||
outweighs many from throwaway keys. A provider cannot vouch for themselves
|
||||
(self-vouches are ignored). Vouches are revocable via NIP-09 deletion.
|
||||
|
||||
### Photographer (kind 35004)
|
||||
|
||||
Skate photography is a distinct craft from filming — it is shot-, light-, and
|
||||
|
|
@ -1879,6 +1926,32 @@ carries an `a` tag pointing back at the crew coordinate, plus a readable
|
|||
Clients query a crew's events with `{ kinds: [31923], "#a": ["35006:<founder>:<crewDtag>"] }`
|
||||
and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop.
|
||||
|
||||
### Events ↔ Musicians (live music)
|
||||
|
||||
An event may feature a **musician** (Lab kind `35011`) — e.g. a band playing a
|
||||
jam or comp afterparty. This reuses the exact `a`-tag pattern as crew hosting: the
|
||||
event carries an `a` tag pointing at the musician's coordinate, plus a readable
|
||||
`music-act` tag.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 31923,
|
||||
"tags": [
|
||||
["d", "bcn-jam-afterparty"],
|
||||
["title", "BCN Jam Afterparty"],
|
||||
["start", "1730000000"],
|
||||
["t", "skateboarding"],
|
||||
["a", "35011:<musicianPubkey>:<musicianDtag>"],
|
||||
["music-act", "The Curb Grinders"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The event page shows a "🎵 Live: [act]" pill. Reciprocally, a musician's Lab
|
||||
profile queries `{ kinds: [31923], "#a": ["35011:<pubkey>:<dtag>"] }` to list
|
||||
its **upcoming gigs**, closing the Music ↔ Events loop. An event may carry both a
|
||||
crew `a` tag and a musician `a` tag.
|
||||
|
||||
### Crew Treasury (NIP-75 Zap Goal)
|
||||
|
||||
A crew may run a transparent, **non-custodial** funding goal for a shared cause
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ const LAB_TYPE_LABEL: Record<number, string> = {
|
|||
35003: 'Editor',
|
||||
35004: 'Photographer',
|
||||
35005: 'Builder',
|
||||
35007: 'Artist',
|
||||
35008: 'Graffiti',
|
||||
35009: 'Tattoo',
|
||||
35010: 'Trainer',
|
||||
35011: 'Musician',
|
||||
};
|
||||
|
||||
/** A single linked-crew row, resolved from a `35006:pubkey:d` coordinate. */
|
||||
|
|
|
|||
55
src/hooks/useGigs.ts
Normal file
55
src/hooks/useGigs.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import {
|
||||
SKATE_MUSICIAN_KIND,
|
||||
CALENDAR_EVENT_KIND,
|
||||
parseSkateEvent,
|
||||
type SkateEvent,
|
||||
} from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Gigs for a musician — calendar events (kind 31923) carrying an `a` tag that
|
||||
* points back at the musician's Lab coordinate (`35011:pubkey:d`).
|
||||
*
|
||||
* This powers the "Upcoming gigs" list on a musician's Lab detail page and
|
||||
* closes the Music ↔ Events loop: an event promoter tags a musician when
|
||||
* creating an event, and the gig automatically surfaces on the act's profile.
|
||||
*/
|
||||
export function useGigs(musicianPubkey?: string, musicianIdentifier?: string, limit = 30) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const musicianCoord =
|
||||
musicianPubkey && musicianIdentifier
|
||||
? `${SKATE_MUSICIAN_KIND}:${musicianPubkey}:${musicianIdentifier}`
|
||||
: '';
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['gigs', musicianCoord, getQueryKeySuffix()],
|
||||
enabled: !!musicianCoord,
|
||||
staleTime: 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [CALENDAR_EVENT_KIND], '#a': [musicianCoord], limit }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const parsed: SkateEvent[] = [];
|
||||
for (const ev of events) {
|
||||
const skateEvent = parseSkateEvent(ev);
|
||||
// Guard against loose relay matches — the `a` tag must be present.
|
||||
const hasRef = ev.tags.some(([t, v]) => t === 'a' && v === musicianCoord);
|
||||
if (skateEvent && hasRef) parsed.push(skateEvent);
|
||||
}
|
||||
|
||||
// Soonest upcoming first, then past.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return parsed.sort((a, b) => {
|
||||
const aFuture = a.start >= now ? 0 : 1;
|
||||
const bFuture = b.start >= now ? 0 : 1;
|
||||
if (aFuture !== bFuture) return aFuture - bFuture;
|
||||
return a.start - b.start;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@ import {
|
|||
SKATE_EDITOR_KIND,
|
||||
SKATE_PHOTOGRAPHER_KIND,
|
||||
SKATE_BUILDER_KIND,
|
||||
SKATE_ARTIST_KIND,
|
||||
SKATE_GRAFFITI_KIND,
|
||||
SKATE_TATTOO_KIND,
|
||||
SKATE_TRAINER_KIND,
|
||||
SKATE_MUSICIAN_KIND,
|
||||
SKATE_TOUR_KIND,
|
||||
SKATE_CREW_KIND,
|
||||
SKATE_RELAY,
|
||||
|
|
@ -128,6 +133,7 @@ export function useGrowthStats() {
|
|||
builderEvents,
|
||||
tourEvents,
|
||||
crewEvents,
|
||||
otherLabEvents,
|
||||
] = await Promise.all([
|
||||
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
|
||||
|
|
@ -139,6 +145,9 @@ export function useGrowthStats() {
|
|||
relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
|
||||
// New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded
|
||||
// into community totals/mappers without their own chart series.
|
||||
relay.query([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []),
|
||||
]);
|
||||
|
||||
// Deduplicate addressable events (keep newest per pubkey+kind+d)
|
||||
|
|
@ -163,6 +172,7 @@ export function useGrowthStats() {
|
|||
const builders = dedup(builderEvents);
|
||||
const tours = dedup(tourEvents);
|
||||
const crews = dedup(crewEvents);
|
||||
const otherLab = dedup(otherLabEvents);
|
||||
|
||||
// ── Monthly bucketing ───────────────────────────────────────────────
|
||||
|
||||
|
|
@ -195,6 +205,8 @@ export function useGrowthStats() {
|
|||
for (const e of builders) { const b = ensureBucket(toMonthKey(e.created_at)); b.builders.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); }
|
||||
// Other Lab crafts contribute to active-mapper counts (no dedicated series).
|
||||
for (const e of otherLab) { const b = ensureBucket(toMonthKey(e.created_at)); b.pubkeys.add(e.pubkey); }
|
||||
|
||||
// Fill any gaps between first and last month so charts have continuous X axis
|
||||
const allKeys = fillMonthGaps(Array.from(bucketMap.keys()));
|
||||
|
|
@ -235,7 +247,7 @@ export function useGrowthStats() {
|
|||
const mapperTotal = new Map<string, number>();
|
||||
|
||||
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); }
|
||||
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews];
|
||||
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews, ...otherLab];
|
||||
for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
|
||||
|
||||
const topMappers: TopMapper[] = Array.from(mapperSpots.entries())
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { VouchCard } from '@/components/VouchCard';
|
|||
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||
import { TrustBadge } from '@/components/TrustBadge';
|
||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||
import { useGigs } from '@/hooks/useGigs';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
|
||||
|
|
@ -83,6 +84,12 @@ export default function CoachDetailPage() {
|
|||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Gigs — only relevant for musicians. The hook is disabled for other types.
|
||||
const { data: gigs = [] } = useGigs(
|
||||
profile?.profileType === 'musician' ? profile.pubkey : undefined,
|
||||
profile?.profileType === 'musician' ? profile.identifier : undefined,
|
||||
);
|
||||
|
||||
const isCoach = profile?.profileType === 'coach';
|
||||
const isFilmer = profile?.profileType === 'filmer';
|
||||
const isEditor = profile?.profileType === 'editor';
|
||||
|
|
@ -396,6 +403,42 @@ export default function CoachDetailPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Upcoming gigs — events that feature this musician */}
|
||||
{isMusician && gigs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
|
||||
<Calendar className={`h-4 w-4 ${accent.text}`} />
|
||||
Upcoming Gigs
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{gigs.slice(0, 6).map((gig) => {
|
||||
const isUpcoming = gig.start >= Math.floor(Date.now() / 1000);
|
||||
return (
|
||||
<Link
|
||||
key={gig.event.id}
|
||||
to={`/event/${gig.pubkey}/${gig.identifier}`}
|
||||
className={`flex items-center gap-3 rounded-lg border ${accent.border} bg-black/50 p-3 transition-colors hover:bg-white/5`}
|
||||
>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${accent.bg}/20`}>
|
||||
<Music className={`h-4 w-4 ${accent.text}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-bold text-white">{gig.title}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(gig.start * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{gig.location ? ` · ${gig.location}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!isUpcoming && (
|
||||
<Badge variant="secondary" className="shrink-0 text-[10px]">Past</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Experience & Rate */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{profile.experience && (
|
||||
|
|
@ -436,6 +479,22 @@ export default function CoachDetailPage() {
|
|||
Send Message
|
||||
</Button>
|
||||
|
||||
{/* Book this act for an event — deep-links to event creation
|
||||
pre-tagged with this musician (Music → Events). */}
|
||||
{isMusician && (
|
||||
<Button
|
||||
className={`w-full gap-2 ${accent.bg} text-white font-bold hover:opacity-90`}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/create-event?type=concert&musician=${profile.pubkey}:${profile.identifier}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Book for a Gig
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Contact Info from Profile */}
|
||||
{sanitizeTel(profile.phone) && (
|
||||
<a href={sanitizeTel(profile.phone)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
|
|||
import { useToast } from '@/hooks/useToast';
|
||||
import { useTranslation } from '@/contexts/LanguageContext';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
|
||||
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||
import { Users } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
Globe,
|
||||
Map,
|
||||
Zap,
|
||||
Music,
|
||||
} from 'lucide-react';
|
||||
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
|
||||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||||
|
|
@ -60,6 +62,13 @@ export default function CreateEventPage() {
|
|||
? crewParam.split(':')
|
||||
: ['', ''];
|
||||
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
|
||||
// Optional featured musician: `?musician=<pubkey>:<identifier>` — a music act
|
||||
// (Lab kind 35011) playing this event. Connects Music ↔ Events.
|
||||
const musicianParam = searchParams.get('musician') ?? '';
|
||||
const [musicianPubkey, musicianIdentifier] = musicianParam.includes(':')
|
||||
? musicianParam.split(':')
|
||||
: ['', ''];
|
||||
const { data: musicAct } = useLabProfile(musicianIdentifier, musicianPubkey);
|
||||
// Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the
|
||||
// "Book for a gig" action on a crew that has a band.
|
||||
const prefillType = searchParams.get('type') ?? '';
|
||||
|
|
@ -351,6 +360,13 @@ export default function CreateEventPage() {
|
|||
tags.push(['crew-name', hostCrew.name]);
|
||||
}
|
||||
|
||||
// Featured musician — link a music act (Lab kind 35011) to the event so it
|
||||
// shows "🎸 Live: [act]" and surfaces on the musician's gig list.
|
||||
if (musicAct && musicAct.profileType === 'musician') {
|
||||
tags.push(['a', `${SKATE_MUSICIAN_KIND}:${musicAct.pubkey}:${musicAct.identifier}`]);
|
||||
tags.push(['music-act', musicAct.title]);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: CALENDAR_EVENT_KIND,
|
||||
content: description.trim(),
|
||||
|
|
@ -424,6 +440,22 @@ export default function CreateEventPage() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{musicAct && musicAct.profileType === 'musician' && (
|
||||
<Card className="mt-4 border-2 border-fuchsia-500/40 bg-fuchsia-500/10">
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-500/20">
|
||||
<Music className="h-5 w-5 text-fuchsia-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-bold text-fuchsia-300">Featuring {musicAct.title}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
This act will be tagged as live music and surfaced on their gig list.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!hasNip05 && (
|
||||
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
|
||||
<CardContent className="p-4">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
|
|||
import { useSkateEvent } from '@/hooks/useSkateEvents';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND } from '@/lib/types';
|
||||
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
|
||||
import { Users } from 'lucide-react';
|
||||
import NotFound from './NotFound';
|
||||
import {
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
Navigation,
|
||||
CalendarPlus,
|
||||
Pencil,
|
||||
Music,
|
||||
} from 'lucide-react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
|
|
@ -112,6 +113,19 @@ export default function EventDetailPage() {
|
|||
return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null;
|
||||
}, [event]);
|
||||
|
||||
// Featured musician — an `a` tag pointing at a musician coordinate (35011:pubkey:d).
|
||||
const musicAct = useMemo(() => {
|
||||
if (!event) return null;
|
||||
const ref = event.event.tags
|
||||
.filter(([t]) => t === 'a')
|
||||
.map(([, v]) => v)
|
||||
.find((v) => v?.startsWith(`${SKATE_MUSICIAN_KIND}:`));
|
||||
if (!ref) return null;
|
||||
const [, mPubkey, mId] = ref.split(':');
|
||||
const name = event.event.tags.find(([t]) => t === 'music-act')?.[1];
|
||||
return mPubkey && mId ? { pubkey: mPubkey, identifier: mId, name } : null;
|
||||
}, [event]);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
|
|
@ -220,15 +234,26 @@ export default function EventDetailPage() {
|
|||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">{event.title}</h1>
|
||||
{crewHost && (
|
||||
<Link
|
||||
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
|
||||
className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Hosted by {crewHost.name || 'a crew'}
|
||||
</Link>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{crewHost && (
|
||||
<Link
|
||||
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
|
||||
>
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Hosted by {crewHost.name || 'a crew'}
|
||||
</Link>
|
||||
)}
|
||||
{musicAct && (
|
||||
<Link
|
||||
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1 text-sm font-bold text-fuchsia-300 transition-colors hover:bg-fuchsia-500/20"
|
||||
>
|
||||
<Music className="h-3.5 w-3.5" />
|
||||
Live: {musicAct.name || 'live music'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwner && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue