Add optional crew band / music project to Skate Crews
Skate scenes and local music go hand in hand — crews can now list a band name + a music link (Spotify/Bandcamp/YouTube/Nostr). - SkateCrew type: new optional bandName / bandUrl fields. - Parser reads `band` + `band-url` tags (band-url validated as http(s)). - CrewForm: dedicated optional "Crew Band" section. - CrewDetailPage: purple band row (linked when a valid URL is present). - CrewsPage cards: small "Band" badge when a crew has a band. - Kept scoped to crews only (not The Lab) since bands aren't a skate-specific craft. - NIP.md: documented the new `band` / `band-url` crew tags. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a371ff3645
commit
b3cda77224
5 changed files with 92 additions and 3 deletions
2
NIP.md
2
NIP.md
|
|
@ -1751,6 +1751,8 @@ bare `p` tag on the crew is only a claim until the member self-labels.
|
|||
| `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 |
|
||||
| `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). |
|
||||
| `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. |
|
||||
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
||||
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
|
||||
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import {
|
|||
import { useCrewGoal } from '@/hooks/useCrewGoal';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone } from 'lucide-react';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music } from 'lucide-react';
|
||||
|
||||
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching'];
|
||||
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
|
||||
|
|
@ -132,6 +132,8 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
const [image, setImage] = useState<string | null>(existing?.image ?? null);
|
||||
const [website, setWebsite] = useState(existing?.website ?? '');
|
||||
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
|
||||
const [bandName, setBandName] = useState(existing?.bandName ?? '');
|
||||
const [bandUrl, setBandUrl] = useState(existing?.bandUrl ?? '');
|
||||
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
|
||||
const [socialInput, setSocialInput] = useState('');
|
||||
const [nip05, setNip05] = useState(existing?.nip05 ?? '');
|
||||
|
|
@ -343,6 +345,10 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
if (image) tags.push(['image', image]);
|
||||
if (website.trim()) tags.push(['website', website.trim()]);
|
||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||
if (bandName.trim()) tags.push(['band', bandName.trim()]);
|
||||
if (bandUrl.trim() && /^https?:\/\//i.test(bandUrl.trim())) {
|
||||
tags.push(['band-url', bandUrl.trim()]);
|
||||
}
|
||||
for (const s of socials) {
|
||||
if (s.trim()) tags.push(['social', s.trim()]);
|
||||
}
|
||||
|
|
@ -801,6 +807,43 @@ export function CrewForm({ existing }: CrewFormProps) {
|
|||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
</div>
|
||||
|
||||
{/* Crew Band — many crews double as local bands. Optional. */}
|
||||
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Music className="h-4 w-4 text-purple-400" />
|
||||
<Label className="text-base font-bold">Crew Band <span className="text-xs font-normal text-gray-500">(optional)</span></Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
Got a band? Skate scenes and local music go hand in hand — drop your band name and a link to the tunes.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-band-name">Band name</Label>
|
||||
<Input
|
||||
id="crew-band-name"
|
||||
placeholder="e.g. The Bail Outs"
|
||||
value={bandName}
|
||||
onChange={(e) => setBandName(e.target.value)}
|
||||
maxLength={100}
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-band-url">Music link</Label>
|
||||
<Input
|
||||
id="crew-band-url"
|
||||
type="url"
|
||||
placeholder="Spotify / Bandcamp / YouTube"
|
||||
value={bandUrl}
|
||||
onChange={(e) => setBandUrl(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="bg-gray-800 border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="space-y-2">
|
||||
<Label>Crew Logo / Photo</Label>
|
||||
|
|
|
|||
|
|
@ -1033,6 +1033,8 @@ export interface SkateCrew {
|
|||
founded?: string; // Year founded (cosmetic)
|
||||
website?: string;
|
||||
instagram?: string;
|
||||
bandName?: string; // Crew's skate band / music project name (`band` tag)
|
||||
bandUrl?: string; // Link to the band's music (Spotify/Bandcamp/YT/Nostr) (`band-url` tag)
|
||||
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)
|
||||
|
|
@ -1570,6 +1572,10 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
|||
const founded = event.tags.find(([name]) => name === 'founded')?.[1];
|
||||
const website = event.tags.find(([name]) => name === 'website')?.[1];
|
||||
const instagram = event.tags.find(([name]) => name === 'instagram')?.[1];
|
||||
// Optional crew band / music project — many crews double as local bands.
|
||||
const bandName = clampStr(event.tags.find(([name]) => name === 'band')?.[1], 100);
|
||||
const bandUrlRaw = event.tags.find(([name]) => name === 'band-url')?.[1]?.trim();
|
||||
const bandUrl = bandUrlRaw && /^https?:\/\//i.test(bandUrlRaw) ? bandUrlRaw : undefined;
|
||||
const socials = event.tags
|
||||
.filter(([name, value]) => name === 'social' && !!value?.trim())
|
||||
.map(([, value]) => value.trim());
|
||||
|
|
@ -1606,6 +1612,8 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
|||
founded,
|
||||
website,
|
||||
instagram,
|
||||
bandName,
|
||||
bandUrl,
|
||||
social,
|
||||
socials,
|
||||
nip05,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } 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 } from 'lucide-react';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare, Zap, ShieldCheck, EyeOff, Music } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function FeedItem({
|
||||
|
|
@ -504,6 +504,33 @@ export default function CrewDetailPage() {
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Crew band / music project */}
|
||||
{crew.bandName && (
|
||||
crew.bandUrl && sanitizeUrl(crew.bandUrl) ? (
|
||||
<a
|
||||
href={sanitizeUrl(crew.bandUrl)!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm p-2 bg-purple-500/10 border border-purple-500/25 rounded hover:bg-purple-500/20 transition-colors"
|
||||
title={`Listen: ${urlDomain(crew.bandUrl)}`}
|
||||
>
|
||||
<Music className="h-4 w-4 text-purple-400 shrink-0" />
|
||||
<span className="truncate text-purple-200">
|
||||
<span className="text-purple-400/70">Band · </span>
|
||||
{crew.bandName}
|
||||
</span>
|
||||
</a>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm p-2 bg-purple-500/10 border border-purple-500/25 rounded">
|
||||
<Music className="h-4 w-4 text-purple-400 shrink-0" />
|
||||
<span className="truncate text-purple-200">
|
||||
<span className="text-purple-400/70">Band · </span>
|
||||
{crew.bandName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-gray-700 space-y-2">
|
||||
{/* Zap the whole crew — split across founder + confirmed members */}
|
||||
{crewZapRecipients.length > 1 && (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/c
|
|||
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
|
||||
import type { CrewActivity } from '@/lib/crewHelpers';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2 } from 'lucide-react';
|
||||
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music } from 'lucide-react';
|
||||
|
||||
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
|
||||
{ key: 'all', label: 'All', icon: Users },
|
||||
|
|
@ -81,6 +81,15 @@ function CrewCard({
|
|||
</span>
|
||||
);
|
||||
})()}
|
||||
{crew.bandName && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full border border-purple-500/30 bg-purple-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-purple-300"
|
||||
title={`Crew band: ${crew.bandName}`}
|
||||
>
|
||||
<Music className="h-2.5 w-2.5" />
|
||||
Band
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(crew.location || distanceKm !== null) && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue