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:
parent
f10aa7971c
commit
28f4ef8c34
4 changed files with 153 additions and 17 deletions
2
NIP.md
2
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: <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 |
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in a new issue