Crew form: refine vibes + multiple socials

- Remove LGBTQ+ and Longboard preset vibes
- Rename "Girls / Womxn" → "Girl / Woman"
- Add custom vibe/focus field: users define their own #hashtag focus
  (normalized to a lowercase hyphenated slug, published as t tags)
- Support multiple "Other Socials" links (SkateCrew.socials[]) with add/remove UI;
  parser collects all social tags; detail page renders each (URL or plain handle)
- Update NIP.md crew vibe taxonomy

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 14:06:37 +07:00
parent f10aa7971c
commit 28f4ef8c34
4 changed files with 153 additions and 17 deletions

2
NIP.md
View file

@ -1683,7 +1683,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `g` | Geohash of the crew's **home turf** (map anchor) |
| `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags: `street`, `transition`, `diy`, `oldschool`, `allterrain`, `longboard`, `bmx-friendly`, `bowl`, `vert`, `cruising`, `downhill`, etc. |
| `t: <vibe>` | 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). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `website`, `instagram`, `social` | Links |

View file

@ -68,12 +68,22 @@ export function CrewForm({ existing }: CrewFormProps) {
const [location, setLocation] = useState(existing?.location ?? '');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [vibes, setVibes] = useState<string[]>(existing?.vibes ?? []);
// Preset vibes (known keys) vs custom vibes (user-defined hashtags).
// Split the existing crew's vibes into the two buckets on load.
const presetKeys = new Set(Object.keys(CREW_VIBE_LABELS));
const [vibes, setVibes] = useState<string[]>(
(existing?.vibes ?? []).filter((v) => presetKeys.has(v))
);
const [customVibes, setCustomVibes] = useState<string[]>(
(existing?.vibes ?? []).filter((v) => !presetKeys.has(v))
);
const [customVibeInput, setCustomVibeInput] = useState('');
const [founded, setFounded] = useState(existing?.founded ?? '');
const [image, setImage] = useState<string | null>(existing?.image ?? null);
const [website, setWebsite] = useState(existing?.website ?? '');
const [instagram, setInstagram] = useState(existing?.instagram ?? '');
const [social, setSocial] = useState(existing?.social ?? '');
const [socials, setSocials] = useState<string[]>(existing?.socials ?? []);
const [socialInput, setSocialInput] = useState('');
const [noffer, setNoffer] = useState(existing?.noffer ?? '');
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
const [memberInput, setMemberInput] = useState('');
@ -89,6 +99,34 @@ export function CrewForm({ existing }: CrewFormProps) {
const toggleVibe = (v: string) =>
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
// Normalize a free-text vibe into a hashtag-friendly slug:
// strip leading #, lowercase, collapse whitespace to hyphens, drop invalid chars.
const normalizeVibe = (raw: string) =>
raw
.trim()
.replace(/^#+/, '')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const addCustomVibe = () => {
const slug = normalizeVibe(customVibeInput);
setCustomVibeInput('');
if (!slug) return;
// Don't duplicate a preset or an existing custom vibe
if (presetKeys.has(slug) || customVibes.includes(slug)) return;
setCustomVibes((prev) => [...prev, slug]);
};
const addSocial = () => {
const val = socialInput.trim();
setSocialInput('');
if (!val || socials.includes(val)) return;
setSocials((prev) => [...prev, val]);
};
const addMember = () => {
const raw = memberInput.trim();
if (!raw) return;
@ -184,11 +222,14 @@ export function CrewForm({ existing }: CrewFormProps) {
}
for (const v of vibes) tags.push(['t', v]);
for (const v of customVibes) tags.push(['t', v]);
if (founded.trim()) tags.push(['founded', founded.trim()]);
if (image) tags.push(['image', image]);
if (website.trim()) tags.push(['website', website.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
for (const s of socials) {
if (s.trim()) tags.push(['social', s.trim()]);
}
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
for (const pk of members) tags.push(['p', pk]);
@ -247,6 +288,48 @@ export function CrewForm({ existing }: CrewFormProps) {
</button>
))}
</div>
{/* Custom vibe / focus (user-defined hashtags) */}
<div className="space-y-2">
<p className="text-xs text-gray-500">Add your own vibe or focus with a #</p>
<div className="flex gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">#</span>
<Input
placeholder="e.g. downhill, night-sessions, no-comply"
value={customVibeInput}
onChange={(e) => setCustomVibeInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addCustomVibe(); } }}
spellCheck={false}
autoCapitalize="none"
className="bg-gray-800 border-gray-700 pl-7"
/>
</div>
<Button type="button" onClick={addCustomVibe} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{customVibes.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{customVibes.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 rounded-xl border border-teal-500/60 bg-teal-500/15 px-3 py-1.5 text-xs font-bold text-teal-300"
>
#{v}
<button
type="button"
onClick={() => setCustomVibes(customVibes.filter((x) => x !== v))}
className="text-teal-400/70 hover:text-red-400"
aria-label={`Remove ${v}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
</div>
{/* Members */}
@ -328,8 +411,40 @@ export function CrewForm({ existing }: CrewFormProps) {
<Input id="crew-website" type="url" placeholder="https://crew.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="crew-social">Other Social</Label>
<Input id="crew-social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
<Label htmlFor="crew-social">Other Socials</Label>
<p className="text-xs text-gray-500">Add as many as you like — YouTube, TikTok, Discord, Telegram, etc.</p>
<div className="flex gap-2">
<Input
id="crew-social"
placeholder="@username or link"
value={socialInput}
onChange={(e) => setSocialInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }}
className="bg-gray-800 border-gray-700"
/>
<Button type="button" onClick={addSocial} className={cn('shrink-0 gap-1', TEAL)}>
<Plus className="h-4 w-4" /> Add
</Button>
</div>
{socials.length > 0 && (
<div className="space-y-2 pt-1">
{socials.map((s, i) => (
<div key={`${s}-${i}`} className="flex items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
<span className="min-w-0 flex-1 truncate text-sm text-gray-200">{s}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400"
onClick={() => setSocials(socials.filter((_, idx) => idx !== i))}
aria-label="Remove social link"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />

View file

@ -761,10 +761,8 @@ export const CREW_VIBE_LABELS: Record<string, string> = {
cruising: 'Cruising / Filming',
oldschool: 'Old School',
allterrain: 'All Terrain',
longboard: 'Longboard',
girls: 'Girls / Womxn',
girls: 'Girl / Woman',
youth: 'Youth / Groms',
lgbtq: 'LGBTQ+',
adaptive: 'Adaptive',
};
@ -781,7 +779,8 @@ export interface SkateCrew {
founded?: string; // Year founded (cosmetic)
website?: string;
instagram?: string;
social?: string;
social?: string; // First social link (backward compat)
socials: string[]; // All additional social links (`social` tags)
noffer?: string; // Optional shared tip jar (CLINK)
members: string[]; // Member pubkeys (hex) via `p` tags
spotRefs: string[]; // Home spots (kind 30818 refs)
@ -1280,7 +1279,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];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const socials = event.tags
.filter(([name, value]) => name === 'social' && !!value?.trim())
.map(([, value]) => value.trim());
const social = socials[0];
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const members = parseMemberPubkeys(event);
@ -1302,6 +1304,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
website,
instagram,
social,
socials,
noffer,
members,
spotRefs,

View file

@ -230,12 +230,30 @@ export default function CrewDetailPage() {
</a>
)}
{sanitizeUrl(crew.social) && (
<a href={sanitizeUrl(crew.social)!} 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.social!)}`}>
<Globe className="h-4 w-4 text-teal-400" />
<span className="truncate">{urlDomain(crew.social!)}</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>
);
})}
<div className="pt-3 border-t border-gray-700 space-y-2">
<ZapButton target={crew.event} className="w-full justify-center" />