Add Skater profiles (kind 35000) + /skaters directory
- New app-native Skater Profile kind 35000 with `skateskater` marker tag, the anti-spam boundary that keeps generic kind-0 Nostr profiles out of the directory (queried only from the community relay). - Skater type, stance/style/level constants, and parser in types.ts. - useSkaters + useUserSkaterProfile hooks. - /skaters directory page: immersive hero, live count, search, style filter chips, ranked skater grid with NIP-05 verification badges and lazy author metadata. - /become-skater opt-in flow (bio, photo, styles, stance, level, years, home spot, location w/ geohash, socials). - Wire "Skaters" into the mobile nav and a cross-link banner in The Lab so plain skaters finally have a home. - Document kind 35000 in NIP.md. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
9599adaf0d
commit
b42c234709
8 changed files with 941 additions and 0 deletions
47
NIP.md
47
NIP.md
|
|
@ -1478,6 +1478,53 @@ The value of a badge is proportional to the reputation of the witness:
|
|||
}
|
||||
```
|
||||
|
||||
## Skater Profile (kind 35000)
|
||||
|
||||
The **Skater Profile** is the app-native identity that makes a Nostr pubkey a
|
||||
*discoverable SkateSpots skater*. It is an addressable event identified by
|
||||
`pubkey + kind + d-tag` (the `d` tag is always `skater-profile`, so a user has
|
||||
exactly one).
|
||||
|
||||
**This is the anti-spam boundary for the `/skaters` directory.** A plain kind-0
|
||||
metadata profile is shared by the entire Nostr network (every social client,
|
||||
every bot) and is therefore never sufficient to appear in the directory. Only
|
||||
events carrying kind `35000` **and** the `["t", "skateskater"]` marker tag —
|
||||
published from within the app, and queried exclusively from the SkateSpots
|
||||
community relay — are listed. This complements relay-level write restrictions:
|
||||
the relay keeps junk out at the source, the marker defines who is a skater.
|
||||
|
||||
Unlike Lab provider profiles, a Skater Profile advertises no paid service — it
|
||||
exists purely for community discovery.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 35000,
|
||||
"content": "<bio / skate story>",
|
||||
"tags": [
|
||||
["d", "skater-profile"],
|
||||
["title", "<display name at publish time>"],
|
||||
["t", "skateskater"],
|
||||
["t", "skateboarding"],
|
||||
["alt", "SkateSpots skater: <display name>"],
|
||||
["stance", "regular | goofy | switch | mongo"],
|
||||
["level", "grom | intermediate | advanced | ripper"],
|
||||
["t", "<style>"],
|
||||
["years", "<years skating>"],
|
||||
["home_spot", "<freeform home spot>"],
|
||||
["location", "<city / area>"],
|
||||
["g", "<geohash, coarse>"],
|
||||
["image", "<action shot url>"],
|
||||
["instagram", "<@handle>"],
|
||||
["website", "<url>"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Recognized `style` values (repeated `t` tags): `street`, `park`, `vert`,
|
||||
`transition`, `bowl`, `cruising`, `downhill`, `freestyle`, `ledges`, `stairs`,
|
||||
`diy`. A valid profile requires the `d` tag, a `title`, and the `skateskater`
|
||||
marker tag.
|
||||
|
||||
## The Lab — Service Profiles
|
||||
|
||||
The Lab connects skaters with service providers. Each provider type is an
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ const BecomeTattooPage = lazy(() => import("./pages/BecomeTattooPage"));
|
|||
const BecomeTrainerPage = lazy(() => import("./pages/BecomeTrainerPage"));
|
||||
const BecomeMusicianPage = lazy(() => import("./pages/BecomeMusicianPage"));
|
||||
const BecomeJudgePage = lazy(() => import("./pages/BecomeJudgePage"));
|
||||
const BecomeSkaterPage = lazy(() => import("./pages/BecomeSkaterPage"));
|
||||
const SkatersPage = lazy(() => import("./pages/SkatersPage"));
|
||||
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
|
||||
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
|
||||
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
|
||||
|
|
@ -103,6 +105,8 @@ export function AppRouter() {
|
|||
<Route path="/become-trainer" element={<BecomeTrainerPage />} />
|
||||
<Route path="/become-musician" element={<BecomeMusicianPage />} />
|
||||
<Route path="/become-judge" element={<BecomeJudgePage />} />
|
||||
<Route path="/skaters" element={<SkatersPage />} />
|
||||
<Route path="/become-skater" element={<BecomeSkaterPage />} />
|
||||
<Route path="/crews" element={<CrewsPage />} />
|
||||
<Route path="/create-crew" element={<CreateCrewPage />} />
|
||||
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
Users,
|
||||
SlidersHorizontal,
|
||||
BookOpen,
|
||||
PersonStanding,
|
||||
} from 'lucide-react';
|
||||
import { OfflineIndicator } from '@/components/OfflineIndicator';
|
||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||
|
|
@ -690,6 +691,13 @@ export function Layout({ children, noPadding }: LayoutProps) {
|
|||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
))}
|
||||
<NavLink
|
||||
href="/skaters"
|
||||
label="Skaters"
|
||||
icon={PersonStanding}
|
||||
isActive={isActive('/skaters')}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
{user && (
|
||||
<NavLink
|
||||
href={`/${nip19.npubEncode(user.pubkey)}`}
|
||||
|
|
|
|||
67
src/hooks/useSkaters.ts
Normal file
67
src/hooks/useSkaters.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SKATE_SKATER_KIND, SKATER_TAG, parseSkater, type Skater } from '@/lib/types';
|
||||
import { useSkateRelays } from '@/hooks/useSkateRelays';
|
||||
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
|
||||
|
||||
const SKATER_D = 'skater-profile';
|
||||
|
||||
/**
|
||||
* List all SkateSpots skater profiles (kind 35000, tagged skateskater).
|
||||
* Queried from the skate relays only — this is the anti-spam boundary:
|
||||
* plain kind-0 Nostr profiles never appear here, only opted-in app skaters.
|
||||
* Deduplicated per author (newest wins), newest-first.
|
||||
*/
|
||||
export function useSkaters(limit = 200) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
const hidden = useAdminModeration();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skaters', limit, getQueryKeySuffix()],
|
||||
select: (skaters: Skater[]) => filterAdminHidden(skaters, SKATE_SKATER_KIND, hidden),
|
||||
queryFn: async () => {
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [SKATE_SKATER_KIND], '#t': [SKATER_TAG], limit }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Deduplicate: keep the newest profile per author.
|
||||
const byAuthor = new Map<string, Skater>();
|
||||
for (const event of events) {
|
||||
const s = parseSkater(event);
|
||||
if (!s) continue;
|
||||
const existing = byAuthor.get(s.pubkey);
|
||||
if (!existing || s.createdAt > existing.createdAt) byAuthor.set(s.pubkey, s);
|
||||
}
|
||||
|
||||
return [...byAuthor.values()].sort((a, b) => b.createdAt - a.createdAt);
|
||||
},
|
||||
staleTime: 30000,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a single user's skater profile (or null). */
|
||||
export function useUserSkaterProfile(pubkey: string | undefined) {
|
||||
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['skater-profile', pubkey, getQueryKeySuffix()],
|
||||
enabled: !!pubkey,
|
||||
queryFn: async () => {
|
||||
if (!pubkey) return null;
|
||||
const signal = AbortSignal.timeout(10000);
|
||||
const events = await querySkateContent(
|
||||
[{ kinds: [SKATE_SKATER_KIND], authors: [pubkey], '#d': [SKATER_D], limit: 1 }],
|
||||
{ signal },
|
||||
);
|
||||
if (events.length === 0) return null;
|
||||
return parseSkater(events[0]);
|
||||
},
|
||||
staleTime: 30000,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
}
|
||||
|
||||
export { SKATER_D };
|
||||
100
src/lib/types.ts
100
src/lib/types.ts
|
|
@ -45,6 +45,7 @@ function clampStr(s: string | undefined, max: number): string | undefined {
|
|||
export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag
|
||||
export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace)
|
||||
export const CALENDAR_EVENT_KIND = 31923;
|
||||
export const SKATE_SKATER_KIND = 35000; // Skater profile — the app-native "I skate here" identity
|
||||
export const SKATE_COACH_KIND = 35001;
|
||||
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
|
||||
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
|
||||
|
|
@ -288,6 +289,105 @@ export function isValidLightningAddress(value: string | undefined | null): value
|
|||
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim());
|
||||
}
|
||||
|
||||
// ─── Skater profile (kind 35000) ───────────────────────────────────────────
|
||||
// The app-native identity that makes a Nostr pubkey a *discoverable SkateSpots
|
||||
// skater*. Publishing this (opt-in) is what lets someone appear in the /skaters
|
||||
// directory — a plain kind-0 profile is NOT enough (that's the anti-spam wall).
|
||||
|
||||
export type SkaterStance = 'regular' | 'goofy' | 'switch' | 'mongo';
|
||||
export type SkaterStyle = 'street' | 'park' | 'vert' | 'transition' | 'bowl' | 'cruising' | 'downhill' | 'freestyle' | 'ledges' | 'stairs' | 'diy';
|
||||
export type SkaterLevel = 'grom' | 'intermediate' | 'advanced' | 'ripper';
|
||||
|
||||
export const SKATER_STANCES: Record<SkaterStance, { label: string; emoji: string }> = {
|
||||
regular: { label: 'Regular', emoji: '🦶' },
|
||||
goofy: { label: 'Goofy', emoji: '🦶' },
|
||||
switch: { label: 'Switch', emoji: '🔄' },
|
||||
mongo: { label: 'Mongo', emoji: '😅' },
|
||||
};
|
||||
|
||||
export const SKATER_STYLES: Record<SkaterStyle, { label: string; emoji: string }> = {
|
||||
street: { label: 'Street', emoji: '🛹' },
|
||||
park: { label: 'Park', emoji: '🏞️' },
|
||||
vert: { label: 'Vert', emoji: '📐' },
|
||||
transition: { label: 'Transition', emoji: '🌊' },
|
||||
bowl: { label: 'Bowl', emoji: '🥣' },
|
||||
cruising: { label: 'Cruising', emoji: '🛴' },
|
||||
downhill: { label: 'Downhill', emoji: '⛰️' },
|
||||
freestyle: { label: 'Freestyle', emoji: '🕺' },
|
||||
ledges: { label: 'Ledges', emoji: '🧱' },
|
||||
stairs: { label: 'Stairs', emoji: '🪜' },
|
||||
diy: { label: 'DIY', emoji: '🔨' },
|
||||
};
|
||||
|
||||
export const SKATER_LEVELS: Record<SkaterLevel, { label: string; emoji: string; color: string }> = {
|
||||
grom: { label: 'Grom', emoji: '🌱', color: '#22c55e' },
|
||||
intermediate: { label: 'Intermediate', emoji: '⚡', color: '#3b82f6' },
|
||||
advanced: { label: 'Advanced', emoji: '🔥', color: '#f59e0b' },
|
||||
ripper: { label: 'Ripper', emoji: '👑', color: '#ff0040' },
|
||||
};
|
||||
|
||||
/** The `t` marker tag that identifies a SkateSpots skater profile. */
|
||||
export const SKATER_TAG = 'skateskater';
|
||||
|
||||
export interface Skater {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
identifier: string;
|
||||
title: string; // display name at publish time
|
||||
bio: string;
|
||||
location?: string;
|
||||
geohash?: string;
|
||||
image?: string;
|
||||
stance?: SkaterStance;
|
||||
styles: SkaterStyle[]; // preferred terrain/disciplines
|
||||
level?: SkaterLevel;
|
||||
yearsSkating?: string;
|
||||
homeSpot?: string; // freeform, or a spot ref
|
||||
instagram?: string;
|
||||
website?: string;
|
||||
createdAt: number;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
export function parseSkater(event: NostrEvent): Skater | null {
|
||||
if (event.kind !== SKATE_SKATER_KIND) return null;
|
||||
// Must carry the skater marker tag — this is the discovery gate.
|
||||
if (!event.tags.some(([n, v]) => n === 't' && v === SKATER_TAG)) return null;
|
||||
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
if (!d || !title) return null;
|
||||
|
||||
const tag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
||||
const knownStyles = Object.keys(SKATER_STYLES) as SkaterStyle[];
|
||||
const styles = event.tags
|
||||
.filter(([n, v]) => n === 't' && (knownStyles as string[]).includes(v))
|
||||
.map(([, v]) => v as SkaterStyle);
|
||||
|
||||
const stance = tag('stance') as SkaterStance | undefined;
|
||||
const level = tag('level') as SkaterLevel | undefined;
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
identifier: d,
|
||||
title: title.slice(0, 120),
|
||||
bio: event.content.slice(0, 2000),
|
||||
location: tag('location'),
|
||||
geohash: tag('g'),
|
||||
image: tag('image'),
|
||||
stance: stance && SKATER_STANCES[stance] ? stance : undefined,
|
||||
styles,
|
||||
level: level && SKATER_LEVELS[level] ? level : undefined,
|
||||
yearsSkating: tag('years'),
|
||||
homeSpot: tag('home_spot'),
|
||||
instagram: tag('instagram'),
|
||||
website: tag('website'),
|
||||
createdAt: event.created_at,
|
||||
event,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkateSpot {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
|
|
|
|||
381
src/pages/BecomeSkaterPage.tsx
Normal file
381
src/pages/BecomeSkaterPage.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import ngeohash from 'ngeohash';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } 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, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { useUserSkaterProfile, SKATER_D } from '@/hooks/useSkaters';
|
||||
import {
|
||||
SKATE_SKATER_KIND,
|
||||
SKATER_TAG,
|
||||
SKATER_STANCES,
|
||||
SKATER_STYLES,
|
||||
SKATER_LEVELS,
|
||||
type SkaterStance,
|
||||
type SkaterStyle,
|
||||
type SkaterLevel,
|
||||
} from '@/lib/types';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Upload, X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles } from 'lucide-react';
|
||||
|
||||
export default function BecomeSkaterPage() {
|
||||
useSeoMeta({
|
||||
title: 'Join the Skater Directory - SkateSpots',
|
||||
description: 'Create your skater profile so the community can find you.',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey || '');
|
||||
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const { data: existing, isLoading: loadingExisting } = useUserSkaterProfile(user?.pubkey);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
|
||||
const avatar = metadata?.picture;
|
||||
|
||||
const [bio, setBio] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [latitude, setLatitude] = useState('');
|
||||
const [longitude, setLongitude] = useState('');
|
||||
const [stance, setStance] = useState<SkaterStance | undefined>();
|
||||
const [styles, setStyles] = useState<SkaterStyle[]>([]);
|
||||
const [level, setLevel] = useState<SkaterLevel | undefined>();
|
||||
const [years, setYears] = useState('');
|
||||
const [homeSpot, setHomeSpot] = useState('');
|
||||
const [instagram, setInstagram] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [image, setImage] = useState<string | null>(null);
|
||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||
|
||||
// Prefill from an existing profile when editing.
|
||||
useEffect(() => {
|
||||
if (existing) {
|
||||
setBio(existing.bio || '');
|
||||
setLocation(existing.location || '');
|
||||
setStance(existing.stance);
|
||||
setStyles(existing.styles || []);
|
||||
setLevel(existing.level);
|
||||
setYears(existing.yearsSkating || '');
|
||||
setHomeSpot(existing.homeSpot || '');
|
||||
setInstagram(existing.instagram || '');
|
||||
setWebsite(existing.website || '');
|
||||
if (existing.image) setImage(existing.image);
|
||||
}
|
||||
}, [existing]);
|
||||
|
||||
const toggleStyle = (s: SkaterStyle) =>
|
||||
setStyles((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
||||
|
||||
const getCurrentLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
toast({ title: 'Geolocation not supported', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
setIsGettingLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLatitude(pos.coords.latitude.toFixed(6));
|
||||
setLongitude(pos.coords.longitude.toFixed(6));
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location found' });
|
||||
},
|
||||
(err) => {
|
||||
setIsGettingLocation(false);
|
||||
toast({ title: 'Location error', description: err.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: 'Photo 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 (!user) return;
|
||||
|
||||
if (styles.length === 0) {
|
||||
toast({ title: 'Pick your styles', description: 'Choose at least one thing you skate.', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tags: string[][] = [
|
||||
['d', SKATER_D],
|
||||
['title', displayName],
|
||||
['t', SKATER_TAG],
|
||||
['t', 'skateboarding'],
|
||||
['alt', `SkateSpots skater: ${displayName}`],
|
||||
];
|
||||
|
||||
if (location.trim()) tags.push(['location', location.trim()]);
|
||||
if (latitude && longitude) {
|
||||
const lat = parseFloat(latitude);
|
||||
const lng = parseFloat(longitude);
|
||||
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
||||
}
|
||||
if (stance) tags.push(['stance', stance]);
|
||||
if (level) tags.push(['level', level]);
|
||||
for (const s of styles) tags.push(['t', s]);
|
||||
if (years.trim()) tags.push(['years', years.trim()]);
|
||||
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
|
||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||
if (website.trim()) tags.push(['website', website.trim()]);
|
||||
if (image) tags.push(['image', image]);
|
||||
|
||||
await createEvent({ kind: SKATE_SKATER_KIND, content: bio.trim(), tags });
|
||||
|
||||
toast({
|
||||
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
|
||||
description: 'Other skaters can now discover you.',
|
||||
});
|
||||
navigate('/skaters');
|
||||
} catch (error) {
|
||||
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 py-12 max-w-xl">
|
||||
<Card className="bg-black/80 border-2 border-[#ff0040]/40">
|
||||
<CardContent className="py-14 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] mb-5">
|
||||
<PersonStanding className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-black uppercase mb-2">Log in to join</h2>
|
||||
<p className="text-gray-400 mb-6">Create your skater profile so the community can find you.</p>
|
||||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const styleEntries = Object.entries(SKATER_STYLES) as [SkaterStyle, { label: string; emoji: string }][];
|
||||
const stanceEntries = Object.entries(SKATER_STANCES) as [SkaterStance, { label: string; emoji: string }][];
|
||||
const levelEntries = Object.entries(SKATER_LEVELS) as [SkaterLevel, { label: string; emoji: string; color: string }][];
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
|
||||
<Link to="/skaters" 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 Skaters
|
||||
</Link>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="relative overflow-hidden rounded-2xl border-2 border-[#ff0040]/30 bg-gradient-to-br from-[#ff0040]/10 via-black to-[#ccff00]/5 p-6 mb-6">
|
||||
<div className="absolute -top-10 -right-10 w-48 h-48 bg-[#ff0040] opacity-10 rounded-full blur-3xl" />
|
||||
<div className="relative flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16 border-2 border-[#ff0040]/50">
|
||||
<AvatarImage src={avatar} alt={displayName} />
|
||||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] font-black">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-black uppercase leading-none">
|
||||
{existing ? 'Edit your' : 'Join the'} <span className="text-[#ff0040]">Skater</span> Directory
|
||||
</h1>
|
||||
<p className="text-gray-400 text-sm mt-1.5 truncate">
|
||||
Publishing as <span className="text-white font-bold">{displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingExisting ? (
|
||||
<div className="flex items-center justify-center py-16 text-gray-500">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading your profile…
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Bio */}
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">Your skate story</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
placeholder="How long you've been skating, what you're into, what you're working on…"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Photo */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot (optional)</Label>
|
||||
{image ? (
|
||||
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800">
|
||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||
<button type="button" onClick={() => setImage(null)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/80 hover:bg-red-500 transition-colors">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="w-28 h-28 rounded-2xl border-2 border-dashed border-white/15 flex flex-col items-center justify-center cursor-pointer hover:border-[#ff0040]/50 hover:bg-white/5 transition-colors">
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin text-gray-500" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-[10px] text-gray-500 uppercase font-bold">Upload</span></>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Styles */}
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div>
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">What you skate *</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Pick everything that fits your style.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{styleEntries.map(([key, { label, emoji }]) => {
|
||||
const active = styles.includes(key);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => toggleStyle(key)}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
|
||||
active
|
||||
? 'bg-[#ff0040] border-[#ff0040] text-white shadow-[0_0_16px_rgba(255,0,64,0.35)] scale-105'
|
||||
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ff0040]/40 hover:text-white',
|
||||
)}
|
||||
>
|
||||
<span>{emoji}</span>{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stance + Level */}
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Stance</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stanceEntries.map(([key, { label, emoji }]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setStance(stance === key ? undefined : key)}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
|
||||
stance === key ? 'bg-[#ccff00] border-[#ccff00] text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
|
||||
)}
|
||||
>
|
||||
<span>{emoji}</span>{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{levelEntries.map(([key, { label, emoji, color }]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setLevel(level === key ? undefined : key)}
|
||||
className={cn(
|
||||
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
|
||||
level === key ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
|
||||
)}
|
||||
style={level === key ? { backgroundColor: color, borderColor: color } : undefined}
|
||||
>
|
||||
<div className="text-lg leading-none mb-0.5">{emoji}</div>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">Years skating</Label>
|
||||
<Input id="years" placeholder="e.g. 8" value={years} onChange={(e) => setYears(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="home" className="text-sm font-bold uppercase tracking-wide text-gray-400">Home spot</Label>
|
||||
<Input id="home" placeholder="Local park / plaza" value={homeSpot} onChange={(e) => setHomeSpot(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Location + socials */}
|
||||
<Card className="bg-black/60 border-white/10">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location" className="text-sm font-bold uppercase tracking-wide text-gray-400">City / area</Label>
|
||||
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-1 border-white/15">
|
||||
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting…</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Helps others find skaters near them. Only a coarse area is shared.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ig" className="text-sm font-bold uppercase tracking-wide text-gray-400">Instagram</Label>
|
||||
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">Website</Label>
|
||||
<Input id="web" placeholder="https://…" value={website} onChange={(e) => setWebsite(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" className="w-full h-12 bg-[#ff0040] hover:bg-[#ff0040]/90 uppercase font-black text-base" disabled={isPublishing || isUploading}>
|
||||
{isPublishing ? (
|
||||
<><Loader2 className="h-5 w-5 mr-2 animate-spin" />Publishing…</>
|
||||
) : (
|
||||
<><Sparkles className="h-5 w-5 mr-2" />{existing ? 'Update my profile' : 'Put me on the map'}</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ import {
|
|||
Users,
|
||||
CheckCircle2,
|
||||
Shield,
|
||||
PersonStanding,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
|
|
@ -1293,6 +1295,25 @@ export default function LabPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Skater directory cross-link ── */}
|
||||
<Link
|
||||
to="/skaters"
|
||||
className="group flex items-center gap-3 rounded-2xl border border-white/10 bg-gradient-to-r from-[#ff0040]/10 via-black to-[#ccff00]/5 p-4 mb-5 hover:border-[#ff0040]/40 transition-all"
|
||||
>
|
||||
<div className="flex-shrink-0 w-11 h-11 rounded-xl bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/25 transition-colors">
|
||||
<PersonStanding className="h-6 w-6 text-[#ff0040]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-black uppercase text-sm leading-tight">
|
||||
Just a <span className="text-[#ff0040]">skater</span>? Not a pro?
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
The Lab is for services. Browse & join the whole skater community →
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-gray-500 group-hover:text-[#ff0040] group-hover:translate-x-0.5 transition-all flex-shrink-0" />
|
||||
</Link>
|
||||
|
||||
{/* ── Sticky toolbar (tabs + search + location + filter button + active chips) ── */}
|
||||
<LabToolbar
|
||||
serviceType={serviceType}
|
||||
|
|
|
|||
313
src/pages/SkatersPage.tsx
Normal file
313
src/pages/SkatersPage.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Layout } from '@/components/Layout';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useSkaters, useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import {
|
||||
SKATER_STYLES,
|
||||
SKATER_STANCES,
|
||||
SKATER_LEVELS,
|
||||
type Skater,
|
||||
type SkaterStyle,
|
||||
} from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PersonStanding, Search, MapPin, Check, Sparkles, Users, ShieldCheck, X } from 'lucide-react';
|
||||
|
||||
// ─── Skater card ────────────────────────────────────────────────────────────
|
||||
function SkaterCard({ skater }: { skater: Skater }) {
|
||||
const author = useAuthor(skater.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const name = metadata?.name ?? skater.title ?? genUserName(skater.pubkey);
|
||||
const avatar = metadata?.picture ?? skater.image;
|
||||
const nip05 = metadata?.nip05;
|
||||
const npub = nip19.npubEncode(skater.pubkey);
|
||||
const level = skater.level ? SKATER_LEVELS[skater.level] : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="group relative rounded-2xl border-2 border-white/10 bg-black/60 overflow-hidden hover:border-[#ff0040]/50 transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
{/* accent glow */}
|
||||
<div className="absolute -top-16 -right-16 w-40 h-40 bg-[#ff0040] opacity-0 group-hover:opacity-10 blur-3xl transition-opacity" />
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative flex-shrink-0">
|
||||
<Avatar className="h-14 w-14 border-2 border-[#ff0040]/30 group-hover:border-[#ff0040]/60 transition-colors">
|
||||
<AvatarImage src={avatar} alt={name} className="object-cover" />
|
||||
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] font-black">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{level && (
|
||||
<span
|
||||
className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full text-black whitespace-nowrap"
|
||||
style={{ backgroundColor: level.color }}
|
||||
>
|
||||
{level.emoji} {level.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<h3 className="font-black text-white truncate group-hover:text-[#ff0040] transition-colors">{name}</h3>
|
||||
{nip05 && <ShieldCheck className="h-3.5 w-3.5 text-[#ccff00] flex-shrink-0" />}
|
||||
</div>
|
||||
{skater.location && (
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1 mt-0.5 truncate">
|
||||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||||
{skater.location}
|
||||
</p>
|
||||
)}
|
||||
{skater.stance && (
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
{SKATER_STANCES[skater.stance].emoji} {SKATER_STANCES[skater.stance].label}
|
||||
{skater.yearsSkating ? ` · ${skater.yearsSkating}y` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styles */}
|
||||
{skater.styles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{skater.styles.slice(0, 4).map((s) => (
|
||||
<span key={s} className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/8 text-gray-300 border border-white/10">
|
||||
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
|
||||
</span>
|
||||
))}
|
||||
{skater.styles.length > 4 && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-white/5 text-gray-500">
|
||||
+{skater.styles.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{skater.bio && (
|
||||
<p className="text-xs text-gray-400 mt-3 line-clamp-2 leading-relaxed">{skater.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SkaterCardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl border-2 border-white/10 bg-black/60 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Skeleton className="h-14 w-14 rounded-full" />
|
||||
<div className="flex-1 space-y-2 pt-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3">
|
||||
<Skeleton className="h-4 w-14 rounded-full" />
|
||||
<Skeleton className="h-4 w-14 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────
|
||||
export default function SkatersPage() {
|
||||
useSeoMeta({
|
||||
title: 'Skaters - SkateSpots',
|
||||
description: 'Discover skaters in the SkateSpots community. Browse by style, stance, level and location.',
|
||||
});
|
||||
|
||||
const { data: skaters, isLoading } = useSkaters();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: myProfile } = useUserSkaterProfile(user?.pubkey);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [styleFilter, setStyleFilter] = useState<SkaterStyle | 'all'>('all');
|
||||
|
||||
// Which styles actually appear (to avoid empty filter chips).
|
||||
const availableStyles = useMemo(() => {
|
||||
const set = new Set<SkaterStyle>();
|
||||
for (const s of skaters ?? []) for (const st of s.styles) set.add(st);
|
||||
return (Object.keys(SKATER_STYLES) as SkaterStyle[]).filter((s) => set.has(s));
|
||||
}, [skaters]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = skaters ?? [];
|
||||
if (styleFilter !== 'all') list = list.filter((s) => s.styles.includes(styleFilter));
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase();
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.title.toLowerCase().includes(q) ||
|
||||
s.location?.toLowerCase().includes(q) ||
|
||||
s.bio.toLowerCase().includes(q) ||
|
||||
nip19.npubEncode(s.pubkey).toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [skaters, styleFilter, query]);
|
||||
|
||||
const total = skaters?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen">
|
||||
{/* ── Hero header ── */}
|
||||
<div className="relative overflow-hidden border-b border-white/10 bg-gradient-to-br from-[#ff0040]/15 via-black to-[#ccff00]/5">
|
||||
<div className="absolute -top-24 -left-16 w-72 h-72 bg-[#ff0040] opacity-15 rounded-full blur-[100px]" />
|
||||
<div className="absolute -bottom-24 -right-16 w-72 h-72 bg-[#ccff00] opacity-10 rounded-full blur-[100px]" />
|
||||
<div className="container relative px-4 py-10 sm:py-14 max-w-6xl">
|
||||
<div className="flex items-center gap-2 text-[#ff0040] mb-3">
|
||||
<PersonStanding className="h-5 w-5" />
|
||||
<span className="text-xs font-black uppercase tracking-[0.2em]">The Community</span>
|
||||
</div>
|
||||
<h1
|
||||
className="text-4xl sm:text-6xl font-black uppercase tracking-tight leading-none"
|
||||
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}
|
||||
>
|
||||
Skaters
|
||||
</h1>
|
||||
<p className="text-gray-300 mt-3 max-w-xl text-base sm:text-lg">
|
||||
Find your people. Browse the crew of skaters repping SkateSpots — by style, stance, level and city.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-6">
|
||||
{!myProfile ? (
|
||||
<Link to="/become-skater">
|
||||
<Button className="h-11 bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Put me on the map
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/become-skater">
|
||||
<Button variant="outline" className="h-11 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10 font-bold gap-2">
|
||||
<Check className="h-4 w-4" />
|
||||
Edit my skater profile
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{total > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="font-black text-white">{total}</span> skater{total !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Search + filters ── */}
|
||||
<div className="sticky top-0 z-20 bg-black/95 backdrop-blur-md border-b border-white/10">
|
||||
<div className="container px-4 py-3 max-w-6xl space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by name, city or npub…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 h-10 bg-white/5 border-white/10"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Style chips */}
|
||||
{availableStyles.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto scrollbar-none pb-0.5">
|
||||
<button
|
||||
onClick={() => setStyleFilter('all')}
|
||||
className={cn(
|
||||
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all whitespace-nowrap',
|
||||
styleFilter === 'all' ? 'bg-white text-black border-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{availableStyles.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStyleFilter(styleFilter === s ? 'all' : s)}
|
||||
className={cn(
|
||||
'flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-bold border transition-all whitespace-nowrap flex items-center gap-1',
|
||||
styleFilter === s ? 'bg-[#ff0040] border-[#ff0040] text-white' : 'bg-white/5 border-white/10 text-gray-400 hover:text-white hover:border-[#ff0040]/40',
|
||||
)}
|
||||
>
|
||||
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grid ── */}
|
||||
<div className="container px-4 py-6 max-w-6xl">
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => <SkaterCardSkeleton key={i} />)}
|
||||
</div>
|
||||
) : (() => {
|
||||
const shown = filtered;
|
||||
return shown.length === 0 ? (
|
||||
<Card className="border-dashed border-2 border-white/10 bg-black/40">
|
||||
<CardContent className="py-16 px-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-white/5 mb-4">
|
||||
<PersonStanding className="h-8 w-8 text-gray-600" />
|
||||
</div>
|
||||
{total === 0 ? (
|
||||
<>
|
||||
<p className="text-xl font-black uppercase text-white mb-2">No skaters yet</p>
|
||||
<p className="text-gray-400 max-w-sm mx-auto mb-6">
|
||||
Be the first to join the directory and put SkateSpots on the map.
|
||||
</p>
|
||||
<Link to="/become-skater">
|
||||
<Button className="bg-[#ff0040] hover:bg-[#ff0040]/90 font-black uppercase gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Put me on the map
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold text-white mb-1">No matches</p>
|
||||
<p className="text-gray-400 text-sm mb-4">Try a different search or clear your filters.</p>
|
||||
<button
|
||||
onClick={() => { setQuery(''); setStyleFilter('all'); }}
|
||||
className="text-xs font-black uppercase tracking-wider text-[#ff0040] hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{shown.map((s) => <SkaterCard key={s.pubkey} skater={s} />)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue