Step 3: Crews — map-anchored collective feature (kind 35006)
- New SkateCrew kind 35006 + type/parser + useCrews/useCrew hooks - /crews directory, /create-crew, /edit-crew, /crew/:pubkey/:identifier pages - Reuses the collective engine: NIP-32 membership attestation (confirmed/pending), CollectiveMembers + ConfirmMembershipCard, ClinkPayButton, SpotPicker - Activity v1: home spots + crew member feed (no events, per plan) - Map: teal/cyan Crews layer at home turf with crew markers + popups; wired through SpotMap props, layer toggles (mobile popover + filters sheet), and mobileLayerCount badge - Nav: Crews added to navItemKeys (desktop header, mobile bottom bar, sidebar) with en/es i18n keys - NIP.md: documents kind 35006, tag taxonomy, and shared membership mechanism Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
657c36d264
commit
f10aa7971c
15 changed files with 1397 additions and 5 deletions
87
NIP.md
87
NIP.md
|
|
@ -1628,10 +1628,95 @@ on the map.
|
|||
}
|
||||
```
|
||||
|
||||
## Skate Crews (kind 35006)
|
||||
|
||||
A **Crew** is a map-anchored collective identity — the crew you skate/roll with,
|
||||
not a service you hire. It answers *"who skates here?"* rather than *"who can I
|
||||
hire?"* (that's The Lab). Crews are addressable events identified by
|
||||
`pubkey + kind + d-tag`.
|
||||
|
||||
A crew is structurally a **collective** (it reuses the exact NIP-32 membership
|
||||
attestation mechanism defined in [The Lab § Collective Membership](#collective-membership-nip-32-attestations))
|
||||
minus the service/rate fields, plus a **home turf** (map anchor) and optional
|
||||
**home spots**. Members are always attested per-member with their own key — a
|
||||
bare `p` tag on the crew is only a claim until the member self-labels.
|
||||
|
||||
### Event Format
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 35006,
|
||||
"content": "Late-night ledge crew. We haunt the plaza. All welcome, no kooks.",
|
||||
"tags": [
|
||||
["d", "barcelona-night-crew"],
|
||||
["title", "Barcelona Night Crew"],
|
||||
["t", "skatecrew"],
|
||||
["image", "https://blossom.example/crew-logo.png"],
|
||||
["g", "sp3e"],
|
||||
["location", "Barcelona, Spain"],
|
||||
["founded", "2019"],
|
||||
["t", "street"],
|
||||
["t", "transition"],
|
||||
["p", "<memberPubkey1>"],
|
||||
["p", "<memberPubkey2>"],
|
||||
["a", "30818:<spotFounder>:<spotDtag>"],
|
||||
["website", "https://bcnnightcrew.example"],
|
||||
["noffer", "noffer1qqs..."],
|
||||
["alt", "Skate crew: Barcelona Night Crew"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Required Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `d` | Unique crew identifier |
|
||||
| `title` | Crew name |
|
||||
| `t: skatecrew` | Self-tag for discovery/querying |
|
||||
|
||||
### Optional Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `image` | Crew logo / photo |
|
||||
| `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. |
|
||||
| `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 |
|
||||
| `noffer` | Optional shared tip jar (CLINK Offer — see below) |
|
||||
|
||||
### Membership
|
||||
|
||||
Crews reuse the collective membership model verbatim. A member confirms
|
||||
belonging by publishing a NIP-32 label (kind `1985`) referencing the crew's
|
||||
`a`-coordinate, `["l", "member", "org.skatespots.membership"]`. Confirmed /
|
||||
pending chips and removal semantics are identical to Lab collectives.
|
||||
|
||||
### Map Integration
|
||||
|
||||
Crews with a `g` (home turf) render on the SkateSpots map as teal pins. Clicking
|
||||
a crew pin opens its profile. A crew's `a`-referenced home spots remain regular
|
||||
spots on the map and cross-link back to the crew.
|
||||
|
||||
### Querying Crews
|
||||
|
||||
```json
|
||||
{
|
||||
"kinds": [35006],
|
||||
"#t": ["skatecrew"],
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
## CLINK Payments (`noffer` tag)
|
||||
|
||||
SkateSpots supports an **optional, opt-in** self-custodial payment code on Lab
|
||||
provider profiles (kinds 35001–35004) and skate shop stalls (kind 30017). This
|
||||
provider profiles (kinds 35001–35004), skate crews (kind 35006), and skate shop
|
||||
stalls (kind 30017). This
|
||||
is layered on top of — never a replacement for — the existing Lightning Address
|
||||
(`lud16` from kind 0) and zap flows.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
|
|||
const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
|
||||
const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage"));
|
||||
const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage"));
|
||||
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
|
||||
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
|
||||
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
|
||||
const CrewDetailPage = lazy(() => import("./pages/CrewDetailPage"));
|
||||
const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
|
||||
const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage"));
|
||||
|
|
@ -84,6 +88,10 @@ export function AppRouter() {
|
|||
<Route path="/become-editor" element={<BecomeEditorPage />} />
|
||||
<Route path="/become-photographer" element={<BecomePhotographerPage />} />
|
||||
<Route path="/become-builder" element={<BecomeBuilderPage />} />
|
||||
<Route path="/crews" element={<CrewsPage />} />
|
||||
<Route path="/create-crew" element={<CreateCrewPage />} />
|
||||
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />
|
||||
<Route path="/crew/:pubkey/:identifier" element={<CrewDetailPage />} />
|
||||
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
|
||||
<Route path="/onboarding" element={<OnboardingPage />} />
|
||||
<Route path="/pyramid" element={<PyramidPage />} />
|
||||
|
|
|
|||
367
src/components/CrewForm.tsx
Normal file
367
src/components/CrewForm.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import { useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SpotPicker } from '@/components/SpotPicker';
|
||||
import { NofferField } from '@/components/NofferField';
|
||||
import {
|
||||
SKATE_CREW_KIND,
|
||||
SKATE_SPOT_KIND,
|
||||
CREW_VIBE_LABELS,
|
||||
isValidNoffer,
|
||||
type SkateCrew,
|
||||
} from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Upload, X, Loader2, MapPin, Plus } from 'lucide-react';
|
||||
|
||||
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
|
||||
const TEAL = 'bg-teal-500 hover:bg-teal-600 text-black';
|
||||
|
||||
function MemberRow({ pubkey, onRemove }: { pubkey: string; onRemove: () => void }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(pubkey);
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/60 p-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="text-xs">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="truncate text-xs text-gray-500">{npub.slice(0, 16)}…</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-gray-400 hover:text-red-400" onClick={onRemove} aria-label="Remove member">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CrewFormProps {
|
||||
/** Existing crew when editing. */
|
||||
existing?: SkateCrew | null;
|
||||
}
|
||||
|
||||
export function CrewForm({ existing }: CrewFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [name, setName] = useState(existing?.name ?? '');
|
||||
const [bio, setBio] = useState(existing?.bio ?? '');
|
||||
const [location, setLocation] = useState(existing?.location ?? '');
|
||||
const [latitude, setLatitude] = useState('');
|
||||
const [longitude, setLongitude] = useState('');
|
||||
const [vibes, setVibes] = useState<string[]>(existing?.vibes ?? []);
|
||||
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 [noffer, setNoffer] = useState(existing?.noffer ?? '');
|
||||
const [members, setMembers] = useState<string[]>(existing?.members ?? []);
|
||||
const [memberInput, setMemberInput] = useState('');
|
||||
const [memberError, setMemberError] = useState<string | null>(null);
|
||||
const [selectedSpots, setSelectedSpots] = useState<string[]>(
|
||||
(existing?.spotRefs ?? []).map((ref) => {
|
||||
const parts = ref.split(':');
|
||||
return `${parts[1]}:${parts[2]}`;
|
||||
})
|
||||
);
|
||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
|
||||
const toggleVibe = (v: string) =>
|
||||
setVibes((prev) => (prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]));
|
||||
|
||||
const addMember = () => {
|
||||
const raw = memberInput.trim();
|
||||
if (!raw) return;
|
||||
let hex: string | null = null;
|
||||
try {
|
||||
if (raw.startsWith('npub1') || raw.startsWith('nostr:npub1')) {
|
||||
const decoded = nip19.decode(raw.replace(/^nostr:/, ''));
|
||||
if (decoded.type === 'npub') hex = decoded.data;
|
||||
} else if (raw.startsWith('nprofile1')) {
|
||||
const decoded = nip19.decode(raw);
|
||||
if (decoded.type === 'nprofile') hex = decoded.data.pubkey;
|
||||
} else if (/^[0-9a-f]{64}$/i.test(raw)) {
|
||||
hex = raw.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
hex = null;
|
||||
}
|
||||
if (!hex) {
|
||||
setMemberError('Enter a valid npub, nprofile, or hex pubkey.');
|
||||
return;
|
||||
}
|
||||
if (members.includes(hex)) {
|
||||
setMemberError('That member is already added.');
|
||||
return;
|
||||
}
|
||||
setMembers([...members, hex]);
|
||||
setMemberInput('');
|
||||
setMemberError(null);
|
||||
};
|
||||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setLatitude(position.coords.latitude.toFixed(6));
|
||||
setLongitude(position.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
},
|
||||
(error) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
try {
|
||||
const tags = await uploadFile(files[0]);
|
||||
const url = tags[0]?.[1];
|
||||
if (url) {
|
||||
setImage(url);
|
||||
toast({ title: 'Logo uploaded' });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
|
||||
}
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast({ title: 'Name required', description: 'Give your crew a name.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identifier = existing?.identifier ?? `crew-${Date.now().toString(36)}`;
|
||||
|
||||
const tags: string[][] = [
|
||||
['d', identifier],
|
||||
['title', name.trim()],
|
||||
['t', 'skatecrew'],
|
||||
['alt', `Skate crew: ${name.trim()}`],
|
||||
];
|
||||
|
||||
if (location.trim()) tags.push(['location', location.trim()]);
|
||||
|
||||
// Home turf geohash — prefer freshly captured coords, else keep existing
|
||||
if (latitude && longitude) {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
||||
} else if (existing?.geohash) {
|
||||
tags.push(['g', existing.geohash]);
|
||||
}
|
||||
|
||||
for (const v of vibes) 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()]);
|
||||
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
|
||||
|
||||
for (const pk of members) tags.push(['p', pk]);
|
||||
|
||||
for (const spotId of selectedSpots) {
|
||||
const [spotPubkey, spotIdentifier] = spotId.split(':');
|
||||
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
|
||||
}
|
||||
|
||||
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||
|
||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
||||
navigate(`/crew/${user?.pubkey}/${identifier}`);
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card className="bg-gray-900 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-teal-400" /> {existing ? 'Edit Crew' : 'Crew Details'}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">Your crew's identity, home turf and members</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-name">Crew Name *</Label>
|
||||
<Input id="crew-name" placeholder="e.g. Backyard Bandits" value={name} onChange={(e) => setName(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-bio">About / Manifesto</Label>
|
||||
<Textarea id="crew-bio" placeholder="Who you are, what you skate, your story..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
|
||||
{/* Vibe tags */}
|
||||
<div className="space-y-3">
|
||||
<Label>Vibe / Focus</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIBE_ENTRIES.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => toggleVibe(key)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
|
||||
vibes.includes(key)
|
||||
? 'bg-teal-500 text-black border-teal-500'
|
||||
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
|
||||
<div>
|
||||
<Label className="flex items-center gap-2 text-teal-400"><Users className="h-4 w-4" /> Members</Label>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Add your crew's Nostr accounts. Each member confirms from their own key — until then they show as “pending”.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="npub1… / nprofile1… / hex pubkey"
|
||||
value={memberInput}
|
||||
onChange={(e) => { setMemberInput(e.target.value); setMemberError(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addMember(); } }}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
className="bg-gray-800 border-gray-700 font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" onClick={addMember} className={cn('shrink-0 gap-1', TEAL)}>
|
||||
<Plus className="h-4 w-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
{memberError && <p className="text-xs text-red-400">{memberError}</p>}
|
||||
{members.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{members.map((pk) => (
|
||||
<MemberRow key={pk} pubkey={pk} onRemove={() => setMembers(members.filter((m) => m !== pk))} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Home spots */}
|
||||
<SpotPicker
|
||||
selectedSpots={selectedSpots}
|
||||
onSpotsChange={setSelectedSpots}
|
||||
maxSpots={10}
|
||||
label="Home Spots"
|
||||
description="Link the spots your crew haunts — they'll connect to your crew on the map"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
|
||||
<Input id="crew-location" placeholder="Bristol, UK" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-founded">Founded (Year)</Label>
|
||||
<Input id="crew-founded" type="number" placeholder="2015" value={founded} onChange={(e) => setFounded(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Home Turf Coordinates (map anchor)</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
</Button>
|
||||
{existing?.geohash && !latitude && (
|
||||
<p className="text-xs text-gray-500">Leave blank to keep the current home turf location.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-bold">Links</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-instagram">Instagram</Label>
|
||||
<Input id="crew-instagram" placeholder="@crew" value={instagram} onChange={(e) => setInstagram(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="crew-website">Website</Label>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<NofferField value={noffer} onChange={setNoffer} />
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="space-y-2">
|
||||
<Label>Crew Logo / Photo</Label>
|
||||
{image ? (
|
||||
<div className="relative w-32 h-32 rounded-2xl overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Crew" className="w-full h-full object-cover" />
|
||||
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="w-32 h-32 rounded-2xl border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={cn('w-full uppercase font-black', TEAL)} disabled={isPublishing || isUploading}>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
|
||||
) : (
|
||||
<><Users className="h-4 w-4 mr-2" />{existing ? 'Save Crew' : 'Create Crew'}</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -45,6 +45,7 @@ import {
|
|||
Heart,
|
||||
CreditCard,
|
||||
Radio,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||
|
|
@ -67,6 +68,7 @@ const navItemKeys = [
|
|||
{ href: '/', labelKey: 'nav.map', icon: MapPin },
|
||||
{ href: '/lab', labelKey: 'nav.lab', icon: GraduationCap },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: Calendar },
|
||||
{ href: '/crews', labelKey: 'nav.crews', icon: Users },
|
||||
];
|
||||
|
||||
// Format sats for display (e.g., 1234 -> "1.2k", 12345 -> "12k")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent } from '@/lib/types';
|
||||
import type { SkateSpot, MarketplaceItem, SkateTour, SkateEvent, SkateCrew } from '@/lib/types';
|
||||
import { SPOT_TYPE_LABELS, formatPrice } from '@/lib/types';
|
||||
import type { SpotWeatherData } from '@/hooks/useSpotWeather';
|
||||
import type { BtcMapPlace } from '@/hooks/useBtcMapPlaces';
|
||||
|
|
@ -235,6 +235,45 @@ const createEventIcon = () => L.divIcon({
|
|||
popupAnchor: [0, -38],
|
||||
});
|
||||
|
||||
// Custom crew marker icon (teal/cyan — crew home turf)
|
||||
const createCrewIcon = (logo?: string) => L.divIcon({
|
||||
className: 'crew-marker',
|
||||
html: `
|
||||
<div style="
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
background: linear-gradient(135deg, #14b8a6, #06b6d4);
|
||||
border-radius: 50% 50% 50% 0;
|
||||
transform: rotate(-45deg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 14px rgba(20,184,166,0.55);
|
||||
border: 3px solid #fff;
|
||||
overflow: hidden;
|
||||
">
|
||||
${logo ? `
|
||||
<img src="${logo}" style="
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: rotate(45deg) scale(1.5);
|
||||
" onerror="this.style.display='none'" />
|
||||
` : `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transform: rotate(45deg);">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
`}
|
||||
</div>
|
||||
`,
|
||||
iconSize: [42, 42],
|
||||
iconAnchor: [21, 42],
|
||||
popupAnchor: [0, -42],
|
||||
});
|
||||
|
||||
// Custom BTC Map marker icon (for Bitcoin-accepting places)
|
||||
const createBtcMapIcon = (isBoosted?: boolean) => L.divIcon({
|
||||
className: 'btcmap-marker',
|
||||
|
|
@ -332,6 +371,7 @@ interface SpotMapProps {
|
|||
marketplaceItems?: MarketplaceItem[];
|
||||
tours?: SkateTour[];
|
||||
events?: SkateEvent[];
|
||||
crews?: SkateCrew[];
|
||||
btcMapPlaces?: BtcMapPlace[];
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
|
|
@ -348,6 +388,7 @@ interface SpotMapProps {
|
|||
showGear?: boolean;
|
||||
showTours?: boolean;
|
||||
showEvents?: boolean;
|
||||
showCrews?: boolean;
|
||||
showBtcMap?: boolean;
|
||||
flyTo?: { lat: number; lng: number; zoom?: number } | null;
|
||||
}
|
||||
|
|
@ -576,6 +617,7 @@ export function SpotMap({
|
|||
marketplaceItems = [],
|
||||
tours = [],
|
||||
events = [],
|
||||
crews = [],
|
||||
btcMapPlaces = [],
|
||||
center = [40.7128, -74.006], // Default to NYC
|
||||
zoom = 3,
|
||||
|
|
@ -592,6 +634,7 @@ export function SpotMap({
|
|||
showGear = true,
|
||||
showTours = true,
|
||||
showEvents = false,
|
||||
showCrews = false,
|
||||
showBtcMap = false,
|
||||
flyTo = null,
|
||||
}: SpotMapProps) {
|
||||
|
|
@ -1125,7 +1168,87 @@ export function SpotMap({
|
|||
markersRef.current.push(eventMarker);
|
||||
});
|
||||
}
|
||||
}, [locationGroups, tours, showTours, events, showEvents, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
|
||||
|
||||
// Add crew markers (crew home turf — teal pins)
|
||||
if (showCrews && crews.length > 0) {
|
||||
crews.forEach((crew) => {
|
||||
if (!crew.geohash) return; // Only plot crews with a home turf
|
||||
|
||||
const decoded = ngeohash.decode(crew.geohash);
|
||||
const crewMarker = L.marker([decoded.latitude, decoded.longitude], {
|
||||
icon: createCrewIcon(crew.image),
|
||||
});
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'p-3 min-w-[240px] max-w-[300px]';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'flex items-start gap-2 mb-3';
|
||||
header.innerHTML = `
|
||||
<div class="w-10 h-10 rounded-lg bg-teal-500/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
${crew.image
|
||||
? `<img src="${crew.image}" class="w-full h-full object-cover" onerror="this.parentElement.innerHTML='<span class=\\'text-lg\\'>🤘</span>'" />`
|
||||
: `<span class="text-lg">🤘</span>`}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold text-base text-white leading-tight">${crew.name}</h3>
|
||||
<p class="text-xs text-teal-400 font-medium">SKATE CREW</p>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(header);
|
||||
|
||||
// Details
|
||||
const details = document.createElement('div');
|
||||
details.className = 'space-y-1.5 mb-3 text-xs text-gray-300';
|
||||
if (crew.location) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">📍 ${crew.location}</div>`;
|
||||
}
|
||||
if (crew.members.length > 0) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">👥 ${crew.members.length} member${crew.members.length === 1 ? '' : 's'}</div>`;
|
||||
}
|
||||
if (crew.founded) {
|
||||
details.innerHTML += `<div class="flex items-center gap-2">📅 Since ${crew.founded}</div>`;
|
||||
}
|
||||
container.appendChild(details);
|
||||
|
||||
// Vibe tags
|
||||
if (crew.vibes.length > 0) {
|
||||
const badges = document.createElement('div');
|
||||
badges.className = 'flex flex-wrap gap-1 mb-3';
|
||||
crew.vibes.slice(0, 4).forEach((vibe) => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold bg-teal-500/20 text-teal-300 border border-teal-500/30';
|
||||
badge.textContent = vibe;
|
||||
badges.appendChild(badge);
|
||||
});
|
||||
container.appendChild(badges);
|
||||
}
|
||||
|
||||
// View button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'w-full inline-flex items-center justify-center rounded-md text-xs font-bold bg-teal-500 text-white hover:bg-teal-600 h-8 px-3 transition-colors';
|
||||
button.textContent = 'View Crew →';
|
||||
button.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/crew/${crew.pubkey}/${crew.identifier}`);
|
||||
};
|
||||
container.appendChild(button);
|
||||
|
||||
crewMarker.bindPopup(container, {
|
||||
maxWidth: 320,
|
||||
className: 'custom-popup crew-popup',
|
||||
});
|
||||
|
||||
if (clusterGroupRef.current) {
|
||||
clusterGroupRef.current.addLayer(crewMarker);
|
||||
} else {
|
||||
crewMarker.addTo(map);
|
||||
}
|
||||
markersRef.current.push(crewMarker);
|
||||
});
|
||||
}
|
||||
}, [locationGroups, tours, showTours, events, showEvents, crews, showCrews, onSpotClick, handleViewDetails, weatherEnabled, weatherData, navigate]);
|
||||
|
||||
// Update BTC Map markers
|
||||
useEffect(() => {
|
||||
|
|
|
|||
39
src/hooks/useCrewFeed.ts
Normal file
39
src/hooks/useCrewFeed.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/**
|
||||
* Recent kind-1 notes from a crew's members — the "crew feed".
|
||||
* Keeps the crew page alive without any new event kinds.
|
||||
*/
|
||||
export function useCrewFeed(memberPubkeys: string[], limit = 30) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['crew-feed', memberPubkeys.slice().sort().join(','), getQueryKeySuffix()],
|
||||
queryFn: async (): Promise<NostrEvent[]> => {
|
||||
if (memberPubkeys.length === 0) return [];
|
||||
const signal = AbortSignal.timeout(8000);
|
||||
|
||||
const events = await querySkateContent(
|
||||
[
|
||||
{
|
||||
kinds: [1],
|
||||
authors: memberPubkeys,
|
||||
limit,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Exclude replies — a crew feed reads better as top-level posts.
|
||||
const topLevel = events.filter(
|
||||
(e) => !e.tags.some(([name]) => name === 'e')
|
||||
);
|
||||
|
||||
return topLevel.sort((a, b) => b.created_at - a.created_at).slice(0, limit);
|
||||
},
|
||||
enabled: memberPubkeys.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
72
src/hooks/useCrews.ts
Normal file
72
src/hooks/useCrews.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_CREW_KIND, parseSkateCrew, type SkateCrew } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
|
||||
export function useCrews(limit = 200) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-crews', limit, getQueryKeySuffix()],
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
const events = await querySkateContent(
|
||||
[
|
||||
{
|
||||
kinds: [SKATE_CREW_KIND],
|
||||
'#t': ['skatecrew'],
|
||||
limit,
|
||||
},
|
||||
],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
const crews: SkateCrew[] = [];
|
||||
for (const event of events) {
|
||||
const crew = parseSkateCrew(event);
|
||||
if (crew) crews.push(crew);
|
||||
}
|
||||
|
||||
return crews.sort((a, b) => b.createdAt - a.createdAt);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCrew(identifier: string, pubkey?: string) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skate-crew', identifier, pubkey, getQueryKeySuffix()],
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
const filter: {
|
||||
kinds: number[];
|
||||
'#d': string[];
|
||||
authors?: string[];
|
||||
limit: number;
|
||||
} = {
|
||||
kinds: [SKATE_CREW_KIND],
|
||||
'#d': [identifier],
|
||||
limit: 1,
|
||||
};
|
||||
if (pubkey) filter.authors = [pubkey];
|
||||
|
||||
const events = await querySkateContent([filter], { signal });
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const mostRecent = events.reduce((latest, current) =>
|
||||
current.created_at > latest.created_at ? current : latest
|
||||
);
|
||||
|
||||
return parseSkateCrew(mostRecent);
|
||||
},
|
||||
enabled: !!identifier,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -52,6 +52,7 @@ const en = {
|
|||
map: 'Shops & Spots',
|
||||
lab: 'The Lab',
|
||||
events: 'Events',
|
||||
crews: 'Crews',
|
||||
wallet: 'Wallet',
|
||||
settings: 'Settings',
|
||||
more: 'More',
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const es = {
|
|||
map: 'Tiendas y Spots',
|
||||
lab: 'El Lab',
|
||||
events: 'Eventos',
|
||||
crews: 'Crews',
|
||||
wallet: 'Billetera',
|
||||
settings: 'Ajustes',
|
||||
more: 'Más',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
|||
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
|
||||
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer profiles
|
||||
export const SKATE_BUILDER_KIND = 35005; // Skate ramp / park builder profiles
|
||||
export const SKATE_CREW_KIND = 35006; // Skate crews (community identity, map-anchored)
|
||||
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
|
||||
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
|
||||
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
|
||||
|
|
@ -749,6 +750,45 @@ export const BUILDER_SCALE_LABELS: Record<string, string> = {
|
|||
event: 'Event / Contest Course',
|
||||
};
|
||||
|
||||
// ----- Crews (community identity, map-anchored) -----
|
||||
|
||||
// Crew vibe / focus tags
|
||||
export const CREW_VIBE_LABELS: Record<string, string> = {
|
||||
street: 'Street',
|
||||
transition: 'Transition / Vert',
|
||||
park: 'Park',
|
||||
diy: 'DIY',
|
||||
cruising: 'Cruising / Filming',
|
||||
oldschool: 'Old School',
|
||||
allterrain: 'All Terrain',
|
||||
longboard: 'Longboard',
|
||||
girls: 'Girls / Womxn',
|
||||
youth: 'Youth / Groms',
|
||||
lgbtq: 'LGBTQ+',
|
||||
adaptive: 'Adaptive',
|
||||
};
|
||||
|
||||
export interface SkateCrew {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
bio: string; // Freeform crew manifesto / about
|
||||
geohash?: string; // Home turf (map anchor)
|
||||
location?: string;
|
||||
image?: string; // Crew logo / photo
|
||||
vibes: string[]; // Style/focus tags
|
||||
founded?: string; // Year founded (cosmetic)
|
||||
website?: string;
|
||||
instagram?: string;
|
||||
social?: string;
|
||||
noffer?: string; // Optional shared tip jar (CLINK)
|
||||
members: string[]; // Member pubkeys (hex) via `p` tags
|
||||
spotRefs: string[]; // Home spots (kind 30818 refs)
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
export interface SkateBuilder {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
|
|
@ -1221,6 +1261,55 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
|
|||
};
|
||||
}
|
||||
|
||||
export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||
if (event.kind !== SKATE_CREW_KIND) return null;
|
||||
|
||||
const hasCrewTag = event.tags.some(([name, value]) => name === 't' && value === 'skatecrew');
|
||||
if (!hasCrewTag) return null;
|
||||
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
const name = event.tags.find(([name]) => name === 'title')?.[1] || event.tags.find(([name]) => name === 'name')?.[1];
|
||||
|
||||
if (!d || !name) return null;
|
||||
if (name.trim().length < MIN_TITLE_LEN || name.length > MAX_TITLE_LEN) return null;
|
||||
|
||||
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
|
||||
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
|
||||
const image = event.tags.find(([name]) => name === 'image')?.[1];
|
||||
const vibes = event.tags.filter(([name, value]) => name === 't' && value !== 'skatecrew').map(([, value]) => value);
|
||||
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 nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
|
||||
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
|
||||
const members = parseMemberPubkeys(event);
|
||||
const spotRefs = event.tags
|
||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||
.map(([, value]) => value);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
name: name.slice(0, MAX_TITLE_LEN),
|
||||
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
|
||||
location,
|
||||
geohash,
|
||||
image,
|
||||
vibes,
|
||||
founded,
|
||||
website,
|
||||
instagram,
|
||||
social,
|
||||
noffer,
|
||||
members,
|
||||
spotRefs,
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSkateTour(event: NostrEvent): SkateTour | null {
|
||||
if (event.kind !== SKATE_TOUR_KIND) return null;
|
||||
|
||||
|
|
|
|||
52
src/pages/CreateCrewPage.tsx
Normal file
52
src/pages/CreateCrewPage.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CrewForm } from '@/components/CrewForm';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { Users, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function CreateCrewPage() {
|
||||
useSeoMeta({
|
||||
title: 'Start a Crew - SkateSpots',
|
||||
description: 'Create your skate crew — home turf, members and spots.',
|
||||
});
|
||||
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8">
|
||||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<p className="text-gray-400 mb-6">You need to log in to start a crew.</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<Link to="/crews" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Crews
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
Start a Crew
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Rally your people — plant your flag on the map</p>
|
||||
</div>
|
||||
<CrewForm />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
294
src/pages/CrewDetailPage.tsx
Normal file
294
src/pages/CrewDetailPage.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, CREW_VIBE_LABELS } from '@/lib/types';
|
||||
import { sanitizeUrl, urlDomain } from '@/lib/sanitizeUrl';
|
||||
import NotFound from './NotFound';
|
||||
import { Users, MapPin, ArrowLeft, Calendar, Globe, Instagram, Edit, MessageSquare } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function FeedItem({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? genUserName(event.pubkey);
|
||||
const npub = nip19.npubEncode(event.pubkey);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/5 bg-black/40 p-3">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<Link to={`/${npub}`}>
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="text-[10px]">{name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
<Link to={`/${npub}`} className="text-sm font-bold text-gray-200 hover:text-white truncate">
|
||||
{name}
|
||||
</Link>
|
||||
<span className="ml-auto text-[11px] text-gray-500">
|
||||
{new Date(event.created_at * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-sm text-gray-300">
|
||||
<NoteContent event={event} className="text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrewDetailPage() {
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { data: crew, isLoading, error } = useCrew(identifier || '', pubkey);
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(crew?.pubkey);
|
||||
|
||||
useSeoMeta({
|
||||
title: crew ? `${crew.name} - SkateSpots Crew` : 'Crew - SkateSpots',
|
||||
description: crew?.bio || 'A skate crew on SkateSpots.',
|
||||
});
|
||||
|
||||
// Feed = founder + listed members
|
||||
const feedAuthors = crew ? [crew.pubkey, ...crew.members] : [];
|
||||
const { data: feed, isLoading: feedLoading } = useCrewFeed(feedAuthors);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8 max-w-4xl">
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !crew) return <NotFound />;
|
||||
|
||||
const npub = nip19.npubEncode(crew.pubkey);
|
||||
const founderName = author.data?.metadata?.name ?? genUserName(crew.pubkey);
|
||||
const isFounder = user?.pubkey === crew.pubkey;
|
||||
const hasMembers = crew.members.length > 0;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8 max-w-4xl">
|
||||
<Link to="/crews" className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Crews
|
||||
</Link>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{/* Main */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Confirm membership prompt (listed pending members only) */}
|
||||
{hasMembers && (
|
||||
<ConfirmMembershipCard
|
||||
kind={SKATE_CREW_KIND}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
collectiveName={crew.name}
|
||||
claimedMembers={crew.members}
|
||||
accentBg="bg-teal-500 hover:bg-teal-600 text-black"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Hero */}
|
||||
<Card className="bg-black/90 border-2 border-teal-500/40 overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<Avatar className="h-20 w-20 rounded-2xl border-2 border-teal-500/40">
|
||||
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
||||
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black text-xl">
|
||||
{crew.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
{crew.name}
|
||||
</h1>
|
||||
<Badge variant="outline" className="gap-1 text-teal-400 border-teal-500/40">
|
||||
<Users className="h-3 w-3" /> Crew
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-400">
|
||||
{crew.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4 text-teal-400" /> {crew.location}
|
||||
</span>
|
||||
)}
|
||||
{crew.founded && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" /> Since {crew.founded}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFounder && (
|
||||
<Link to={`/edit-crew/${crew.pubkey}/${crew.identifier}`}>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 border-teal-500/40 text-teal-400 hover:bg-teal-500/10">
|
||||
<Edit className="h-3.5 w-3.5" /> Edit
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{crew.bio && (
|
||||
<p className="text-gray-300 whitespace-pre-wrap mb-4">{crew.bio}</p>
|
||||
)}
|
||||
|
||||
{crew.vibes.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{crew.vibes.map((v) => (
|
||||
<Badge key={v} className="bg-teal-500/15 text-teal-300 border-teal-500/30">
|
||||
{CREW_VIBE_LABELS[v] || v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Crew feed */}
|
||||
<Card className="bg-black/90 border-2 border-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-black uppercase text-white">
|
||||
<MessageSquare className="h-5 w-5 text-teal-400" /> Crew Feed
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{feedLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-white/5 bg-black/40 p-3 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
) : feed && feed.length > 0 ? (
|
||||
feed.map((ev) => <FeedItem key={ev.id} event={ev} />)
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
No recent posts from the crew yet.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Founder / links / support */}
|
||||
<Card className="bg-black/90 border-2 border-teal-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-teal-400 uppercase font-black flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Crew
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link to={`/${npub}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={author.data?.metadata?.picture} alt={founderName} />
|
||||
<AvatarFallback className="text-[10px]">{founderName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">Started by {founderName}</span>
|
||||
</Link>
|
||||
|
||||
{sanitizeUrl(crew.website) && (
|
||||
<a href={sanitizeUrl(crew.website)!} 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.website!)}`}>
|
||||
<Globe className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">{urlDomain(crew.website!)}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{crew.instagram && (
|
||||
<a href={`https://instagram.com/${crew.instagram.replace('@', '')}`} 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">
|
||||
<Instagram className="h-4 w-4 text-teal-400" />
|
||||
<span className="truncate">@{crew.instagram.replace('@', '')}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-gray-700 space-y-2">
|
||||
<ZapButton target={crew.event} className="w-full justify-center" />
|
||||
{crew.noffer && (
|
||||
<ClinkPayButton noffer={crew.noffer} recipientName={crew.name} className="w-full justify-center" />
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members */}
|
||||
{hasMembers && (
|
||||
<CollectiveMembers
|
||||
kind={SKATE_CREW_KIND}
|
||||
founderPubkey={crew.pubkey}
|
||||
identifier={crew.identifier}
|
||||
claimedMembers={crew.members}
|
||||
accentText="text-teal-400"
|
||||
accentBorder="border-teal-500/40"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Home spots */}
|
||||
{crew.spotRefs.length > 0 && (
|
||||
<Card className="bg-black/90 border-2 border-white/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
|
||||
<MapPin className="h-5 w-5 text-teal-400" /> Home Spots
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{crew.spotRefs.map((ref) => {
|
||||
const [, spotPubkey, spotD] = ref.split(':');
|
||||
return (
|
||||
<Link
|
||||
key={ref}
|
||||
to={`/spot/${spotPubkey}/${spotD}`}
|
||||
className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<span className="text-base">🛹</span>
|
||||
<span className="truncate">View spot</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link to="/" className="block text-center text-xs text-teal-400 hover:text-teal-300 pt-1">
|
||||
See on the map →
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
154
src/pages/CrewsPage.tsx
Normal file
154
src/pages/CrewsPage.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { CREW_VIBE_LABELS, type SkateCrew } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Users, Plus, Search, MapPin, X } from 'lucide-react';
|
||||
|
||||
function CrewCard({ crew }: { crew: SkateCrew }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/crew/${crew.pubkey}/${crew.identifier}`}
|
||||
className="group flex items-center gap-3 rounded-2xl border border-teal-500/20 bg-white/[0.03] p-3 transition-all hover:border-teal-500/50 hover:bg-white/[0.06] active:scale-[0.99]"
|
||||
>
|
||||
<Avatar className="h-14 w-14 rounded-2xl border-2 border-teal-500/30">
|
||||
<AvatarImage src={crew.image} alt={crew.name} className="object-cover" />
|
||||
<AvatarFallback className="rounded-2xl bg-teal-900/40 text-teal-300 font-black">
|
||||
{crew.name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate font-black text-white leading-tight flex items-center gap-1.5">
|
||||
{crew.name}
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full bg-teal-500/15 px-1.5 py-0.5 text-[10px] font-bold text-teal-400">
|
||||
<Users className="h-2.5 w-2.5" /> {crew.members.length}
|
||||
</span>
|
||||
</h3>
|
||||
{crew.location && (
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-gray-400 truncate">
|
||||
<MapPin className="h-3 w-3 text-teal-400 flex-shrink-0" />
|
||||
{crew.location}
|
||||
</p>
|
||||
)}
|
||||
{crew.vibes.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{crew.vibes.slice(0, 3).map((v) => (
|
||||
<span key={v} className="inline-flex items-center rounded-full border border-teal-700/50 bg-teal-900/30 px-2 py-0.5 text-[10px] font-bold text-teal-300">
|
||||
{CREW_VIBE_LABELS[v] || v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CrewsPage() {
|
||||
useSeoMeta({
|
||||
title: 'Crews - SkateSpots',
|
||||
description: 'Find your people. Discover local skate crews, collectives and communities near you.',
|
||||
});
|
||||
|
||||
const { data: crews, isLoading } = useCrews(200);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!crews) return [];
|
||||
if (!query.trim()) return crews;
|
||||
const q = query.toLowerCase();
|
||||
return crews.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.location?.toLowerCase().includes(q) ||
|
||||
c.bio?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [crews, query]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black uppercase text-teal-400 flex items-center gap-2" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
<Users className="h-7 w-7" /> Crews
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1 text-sm">Find your people — local crews, collectives & communities</p>
|
||||
</div>
|
||||
<Link to="/create-crew">
|
||||
<Button className="gap-2 bg-teal-500 hover:bg-teal-600 text-black font-black uppercase text-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Start a Crew</span>
|
||||
<span className="sm:hidden">Start</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mb-5">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search crews by name or city…"
|
||||
className="w-full h-10 pl-9 pr-9 rounded-xl bg-white/5 border border-white/10 text-sm text-white placeholder:text-gray-600 focus:outline-none focus:border-teal-500/50 transition-all"
|
||||
/>
|
||||
{query && (
|
||||
<button onClick={() => setQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-2xl border border-white/5 p-3">
|
||||
<Skeleton className="h-14 w-14 rounded-2xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 px-4 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
|
||||
<Users className="h-8 w-8 text-gray-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-black mb-2 text-white">
|
||||
{query ? 'No crews match' : 'No crews yet'}
|
||||
</h3>
|
||||
<p className="text-gray-500 text-sm max-w-xs mb-4">
|
||||
{query ? 'Try a different search.' : 'Be the first to plant your crew on the map.'}
|
||||
</p>
|
||||
{!query && (
|
||||
<Link to="/create-crew">
|
||||
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide bg-teal-500 hover:bg-teal-600 text-black transition-all active:scale-95')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Start a Crew
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filtered.map((crew) => (
|
||||
<CrewCard key={`${crew.pubkey}:${crew.identifier}`} crew={crew} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
85
src/pages/EditCrewPage.tsx
Normal file
85
src/pages/EditCrewPage.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { CrewForm } from '@/components/CrewForm';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useCrew } from '@/hooks/useCrews';
|
||||
import NotFound from './NotFound';
|
||||
import { Users, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function EditCrewPage() {
|
||||
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: crew, isLoading } = useCrew(identifier || '', pubkey);
|
||||
|
||||
useSeoMeta({
|
||||
title: crew ? `Edit ${crew.name} - SkateSpots` : 'Edit Crew - SkateSpots',
|
||||
description: 'Edit your skate crew.',
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8">
|
||||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Login Required</h2>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8 max-w-2xl">
|
||||
<Skeleton className="h-6 w-32 mb-6" />
|
||||
<Skeleton className="h-[600px] w-full" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!crew) return <NotFound />;
|
||||
|
||||
// Only the crew's founder can edit it
|
||||
if (crew.pubkey !== user.pubkey) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-8">
|
||||
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
|
||||
<CardContent className="py-12 text-center">
|
||||
<Users className="h-12 w-12 mx-auto mb-4 text-gray-600" />
|
||||
<h2 className="text-xl font-bold mb-2">Not your crew</h2>
|
||||
<p className="text-gray-400">Only the crew founder can edit this crew.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||||
<div className="mb-6">
|
||||
<Link to={`/crew/${crew.pubkey}/${crew.identifier}`} className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Crew
|
||||
</Link>
|
||||
<h1 className="text-3xl font-black uppercase text-teal-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
||||
Edit Crew
|
||||
</h1>
|
||||
</div>
|
||||
<CrewForm existing={crew} />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import { useShops } from '@/hooks/useShops';
|
|||
import { useMarketplaceItems } from '@/hooks/useMarketplace';
|
||||
import { useTours } from '@/hooks/useTours';
|
||||
import { useUpcomingSkateEvents } from '@/hooks/useSkateEvents';
|
||||
import { useCrews } from '@/hooks/useCrews';
|
||||
import { useBtcMapPlaces } from '@/hooks/useBtcMapPlaces';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowing } from '@/hooks/useFollowing';
|
||||
|
|
@ -547,6 +548,8 @@ export default function MapPage() {
|
|||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||||
// Upcoming events for the (opt-in) Events map layer
|
||||
const { data: upcomingEvents } = useUpcomingSkateEvents(100);
|
||||
// Crews for the (opt-in) Crews map layer
|
||||
const { data: crews } = useCrews(200);
|
||||
|
||||
// Check if coming with ?view=gear to focus on marketplace
|
||||
const viewParam = searchParams.get('view');
|
||||
|
|
@ -574,6 +577,8 @@ export default function MapPage() {
|
|||
const [showTours, setShowTours] = useState(!focusGear);
|
||||
// Events layer — off by default; users opt in via the Layers control
|
||||
const [showEvents, setShowEvents] = useState(false);
|
||||
// Crews layer — off by default; users opt in via the Layers control
|
||||
const [showCrews, setShowCrews] = useState(false);
|
||||
const [showBtcMap, setShowBtcMap] = useState(false);
|
||||
|
||||
// Mobile filters sheet
|
||||
|
|
@ -904,8 +909,8 @@ export default function MapPage() {
|
|||
// Count active layer filters for mobile badge
|
||||
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
|
||||
// Count of the primary layers active — shown as a badge on the Layers button
|
||||
const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents].filter(Boolean).length;
|
||||
const totalPrimaryLayers = 5;
|
||||
const mobileLayerCount = [showSpots, showShops, showGear, showTours, showEvents, showCrews].filter(Boolean).length;
|
||||
const totalPrimaryLayers = 6;
|
||||
const activeCategoryCount = selectedCategories.length;
|
||||
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
|
||||
|
||||
|
|
@ -1055,6 +1060,17 @@ export default function MapPage() {
|
|||
>
|
||||
📅 Events
|
||||
</Button>
|
||||
<Button
|
||||
variant={showCrews ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'justify-center gap-1 h-9 text-xs px-2',
|
||||
showCrews ? 'bg-teal-500 hover:bg-teal-600' : ''
|
||||
)}
|
||||
onClick={() => setShowCrews(!showCrews)}
|
||||
>
|
||||
🤘 Crews
|
||||
</Button>
|
||||
<Button
|
||||
variant={showBtcMap ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
|
|
@ -1209,6 +1225,7 @@ export default function MapPage() {
|
|||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
].map(({ key, label, dot, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
@ -1435,11 +1452,13 @@ export default function MapPage() {
|
|||
weatherEnabled={weatherEnabled}
|
||||
weatherData={weatherData}
|
||||
events={upcomingEvents || []}
|
||||
crews={crews || []}
|
||||
showSpots={showSpots}
|
||||
showShops={showShops}
|
||||
showGear={showGear}
|
||||
showTours={showTours}
|
||||
showEvents={showEvents}
|
||||
showCrews={showCrews}
|
||||
showBtcMap={showBtcMap}
|
||||
flyTo={flyTo}
|
||||
/>
|
||||
|
|
@ -1578,6 +1597,7 @@ export default function MapPage() {
|
|||
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
|
||||
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
|
||||
{ key: 'events', label: 'Events', dot: '#ccff00', active: showEvents, toggle: () => setShowEvents(v => !v) },
|
||||
{ key: 'crews', label: 'Crews', dot: '#14b8a6', active: showCrews, toggle: () => setShowCrews(v => !v) },
|
||||
].map(({ key, label, dot, active, toggle }) => (
|
||||
<button
|
||||
key={key}
|
||||
|
|
|
|||
Loading…
Reference in a new issue