From 28f4ef8c3435d2de317c10308a60ad55354ed1f3 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 14:06:37 +0700 Subject: [PATCH] Crew form: refine vibes + multiple socials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- NIP.md | 2 +- src/components/CrewForm.tsx | 125 +++++++++++++++++++++++++++++++++-- src/lib/types.ts | 13 ++-- src/pages/CrewDetailPage.tsx | 30 +++++++-- 4 files changed, 153 insertions(+), 17 deletions(-) diff --git a/NIP.md b/NIP.md index fd5c4d4..a25fc56 100644 --- a/NIP.md +++ b/NIP.md @@ -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: ` | Style/focus tags: `street`, `transition`, `diy`, `oldschool`, `allterrain`, `longboard`, `bmx-friendly`, `bowl`, `vert`, `cruising`, `downhill`, etc. | +| `t: ` | 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 | diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index 9d56131..ef102f2 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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(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( + (existing?.vibes ?? []).filter((v) => presetKeys.has(v)) + ); + const [customVibes, setCustomVibes] = useState( + (existing?.vibes ?? []).filter((v) => !presetKeys.has(v)) + ); + const [customVibeInput, setCustomVibeInput] = useState(''); const [founded, setFounded] = useState(existing?.founded ?? ''); const [image, setImage] = useState(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(existing?.socials ?? []); + const [socialInput, setSocialInput] = useState(''); const [noffer, setNoffer] = useState(existing?.noffer ?? ''); const [members, setMembers] = useState(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) { ))} + + {/* Custom vibe / focus (user-defined hashtags) */} +
+

Add your own vibe or focus with a #

+
+
+ # + 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" + /> +
+ +
+ {customVibes.length > 0 && ( +
+ {customVibes.map((v) => ( + + #{v} + + + ))} +
+ )} +
{/* Members */} @@ -328,8 +411,40 @@ export function CrewForm({ existing }: CrewFormProps) { setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
- - setSocial(e.target.value)} className="bg-gray-800 border-gray-700" /> + +

Add as many as you like — YouTube, TikTok, Discord, Telegram, etc.

+
+ setSocialInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addSocial(); } }} + className="bg-gray-800 border-gray-700" + /> + +
+ {socials.length > 0 && ( +
+ {socials.map((s, i) => ( +
+ {s} + +
+ ))} +
+ )}
diff --git a/src/lib/types.ts b/src/lib/types.ts index 81f3dfd..8708d7f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -761,10 +761,8 @@ export const CREW_VIBE_LABELS: Record = { 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, diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 594ad26..8f7e2a8 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -230,12 +230,30 @@ export default function CrewDetailPage() { )} - {sanitizeUrl(crew.social) && ( - - - {urlDomain(crew.social!)} - - )} + {crew.socials.map((s, i) => { + const url = sanitizeUrl(s); + return url ? ( + + + {urlDomain(s)} + + ) : ( +
+ + {s} +
+ ); + })}