SkateSpots/src/pages/EditSpotPage.tsx
shakespeare.diy a7b7e07e86 Add spot "Shelter" (weather protection) field
Skaters need to know if a spot is roofed/indoor so weather info isn't
misleading — a covered or indoor spot is still skateable in the rain. Added a
4-way shelter level: outdoor (default) / partial / covered / indoor.

Data model (types.ts + skateTags.ts):
- SkateSpot.shelter parsed from a `['shelter', value]` tag (validated to known
  values; absent/unknown → treated as outdoor).
- shelterOptions / DEFAULT_SHELTER / SHELTERED_VALUES + ShelterType exported.
- SHELTER_INFO display map (emoji/label/short/color) for badges.

Publishing (CreateSpotPage + EditSpotPage):
- New "Shelter" selector reusing the exact selectable-Card pattern used by
  Access Type / Spot Type (cn() borders, neon ring, corner check badge).
- Emits `['shelter', value]` always, plus a queryable `['t', value]` for
  sheltered spots (indoor/covered/partial) so "dry spots" filtering + Sasquatch
  search work at the relay level. Outdoor adds no t tag.
- EditSpotPage initialises from the existing spot and preserves the value.

Display:
- Shelter badge on SpotDetailPage, SpotCard, and the MapPage popup — only shown
  for sheltered spots (outdoor stays unbadged to avoid clutter).

Docs:
- NIP.md: shelter added to the optional-tags table, a dedicated Shelter section
  explaining values + the queryable-t-tag behaviour, and an indoor example.

Build clean, no console errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-10 11:38:43 +07:00

695 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useParams } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
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 { Skeleton } from '@/components/ui/skeleton';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useSkateSpot } from '@/hooks/useSkateSpots';
import { LoginArea } from '@/components/auth/LoginArea';
import { TagInput } from '@/components/TagInput';
import { SKATE_SPOT_KIND } from '@/lib/types';
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
import { cn } from '@/lib/utils';
import {
MapPin,
Upload,
X,
Loader2,
ArrowLeft,
AlertTriangle,
Check,
} from 'lucide-react';
import { Link } from 'react-router-dom';
import NotFound from './NotFound';
function EditSpotSkeleton() {
return (
<Layout>
<div className="container px-4 py-8 max-w-2xl">
<Skeleton className="h-6 w-32 mb-4" />
<Skeleton className="h-10 w-64 mb-2" />
<Skeleton className="h-5 w-48 mb-6" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-10 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
</Layout>
);
}
export default function EditSpotPage() {
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { data: spot, isLoading, error } = useSkateSpot(identifier || '', pubkey);
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Form state
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [tags, setTags] = useState<string[]>([]);
const [accessType, setAccessType] = useState<'public' | 'private' | 'locals'>('public');
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [initialized, setInitialized] = useState(false);
useSeoMeta({
title: spot ? `Edit ${spot.title} - SkateSpots` : 'Edit Spot - SkateSpots',
description: 'Edit your skateboarding spot.',
});
// Initialize form with existing data
useEffect(() => {
if (spot && !initialized) {
setTitle(spot.title);
setDescription(spot.description || '');
setLocation(spot.location || '');
setAccessType(spot.accessType || 'public');
setShelter(spot.shelter || DEFAULT_SHELTER);
setContactInfo(spot.contactInfo || '');
setImages(spot.images);
// Set category (new format) or extract from types (legacy)
setSelectedCategory(spot.category || '');
// Set tags (new format)
setTags(spot.tags || []);
// Decode geohash to get coordinates
if (spot.geohash) {
const { latitude: lat, longitude: lng } = ngeohash.decode(spot.geohash);
setLatitude(lat.toFixed(6));
setLongitude(lng.toFixed(6));
}
setInitialized(true);
}
}, [spot, initialized]);
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({
title: 'Geolocation not supported',
description: 'Your browser does not support geolocation.',
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',
description: 'Your current location has been set.',
});
},
(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 file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImages((prev) => [...prev, url]);
toast({
title: 'Image uploaded',
description: 'Your image has been uploaded successfully.',
});
}
} catch (error) {
toast({
title: 'Upload failed',
description: error instanceof Error ? error.message : 'Failed to upload image',
variant: 'destructive',
});
}
e.target.value = '';
};
const removeImage = (url: string) => {
setImages((prev) => prev.filter((img) => img !== url));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!spot) return;
if (!title.trim()) {
toast({
title: 'Title required',
description: 'Please enter a name for this spot.',
variant: 'destructive',
});
return;
}
if (!latitude || !longitude) {
toast({
title: 'Location required',
description: 'Please enter coordinates or use your current location.',
variant: 'destructive',
});
return;
}
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
toast({
title: 'Invalid coordinates',
description: 'Please enter valid latitude (-90 to 90) and longitude (-180 to 180).',
variant: 'destructive',
});
return;
}
if (!selectedCategory) {
toast({
title: 'Spot type required',
description: 'Please pick a spot type so skaters know what they’re rolling up to.',
variant: 'destructive',
});
return;
}
try {
// Generate geohash from coordinates (7 chars = ~153m precision)
const geohash = ngeohash.encode(lat, lng, 7);
// Build event tags - use the same identifier to update the addressable event
const eventTags: string[][] = [
['d', spot.identifier],
['title', title.trim()],
['g', geohash],
['t', 'skatespot'],
['access', accessType],
['lat', lat.toString()],
['lon', lng.toString()],
['alt', `Skate spot: ${title.trim()}`],
];
if (location.trim()) {
eventTags.push(['location', location.trim()]);
}
if (contactInfo.trim()) {
eventTags.push(['contact', contactInfo.trim()]);
}
// Shelter / weather-protection level (see CreateSpotPage for rationale).
eventTags.push(['shelter', shelter]);
if (SHELTERED_VALUES.includes(shelter)) {
eventTags.push(['t', shelter]);
}
// Add category as dedicated tag (new format)
if (selectedCategory) {
eventTags.push(['category', selectedCategory]);
// Also add as t tag for backwards compatibility and relay filtering
eventTags.push(['t', selectedCategory]);
}
// Add all freeform tags
for (const tag of tags) {
eventTags.push(['t', tag]);
}
for (const imageUrl of images) {
// Use imeta tag for NIP-92 compatibility
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
}
await createEvent({
kind: SKATE_SPOT_KIND,
content: description.trim(),
tags: eventTags,
});
toast({
title: 'Spot updated!',
description: 'Your changes have been published to the network.',
});
navigate(`/spot/${user?.pubkey}/${spot.identifier}`);
} catch (error) {
toast({
title: 'Failed to update',
description: error instanceof Error ? error.message : 'Something went wrong',
variant: 'destructive',
});
}
};
if (isLoading) {
return <EditSpotSkeleton />;
}
if (error || !spot) {
return <NotFound />;
}
// Check if user is the owner
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-dashed">
<CardContent className="py-12 text-center">
<MapPin className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-muted-foreground mb-6">
You need to log in to edit this spot.
</p>
<LoginArea className="justify-center" />
</CardContent>
</Card>
</div>
</Layout>
);
}
if (user.pubkey !== spot.pubkey) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto border-destructive">
<CardContent className="py-12 text-center">
<AlertTriangle className="h-12 w-12 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-muted-foreground mb-6">
You can only edit spots that you created.
</p>
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
<Button variant="outline">Back to Spot</Button>
</Link>
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 py-8 max-w-2xl">
<div className="mb-6">
<Link
to={`/spot/${spot.pubkey}/${spot.identifier}`}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Spot
</Link>
<h1 className="text-3xl font-bold">Edit Spot</h1>
<p className="text-muted-foreground mt-1">
Update the details for {spot.title}
</p>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Spot Details</CardTitle>
<CardDescription>
Update the information about this spot. All fields marked with * are required.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Spot Name *</Label>
<Input
id="title"
placeholder="e.g., Hollywood High School"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe the spot, obstacles, best times to skate, security info, etc."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</div>
{/* Location Address */}
<div className="space-y-2">
<Label htmlFor="location">Address / Location</Label>
<Input
id="location"
placeholder="e.g., 1521 N Highland Ave, Los Angeles, CA"
value={location}
onChange={(e) => setLocation(e.target.value)}
/>
</div>
{/* Coordinates */}
<div className="space-y-2">
<Label>Coordinates *</Label>
<div className="grid grid-cols-2 gap-4">
<div>
<Input
placeholder="Latitude (e.g., 34.1012)"
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
required
/>
</div>
<div>
<Input
placeholder="Longitude (e.g., -118.3389)"
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
required
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={getCurrentLocation}
disabled={isGettingLocation}
className="mt-2"
>
{isGettingLocation ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Getting location...
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
Use my current location
</>
)}
</Button>
</div>
{/* Access Type */}
<div className="space-y-3">
<Label className="text-base font-bold">Access Type *</Label>
<div className="space-y-2">
{/* Public */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'public'
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setAccessType('public')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🌍</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Public - Open to Everyone</h4>
<p className="text-sm text-gray-400">Skate parks, public plazas, street spots</p>
</div>
{accessType === 'public' && (
<div className="w-6 h-6 rounded-full bg-[#ff0040] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Private */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'private'
? "bg-[#ff6600]/10 border-[#ff6600] ring-2 ring-[#ff6600]/50"
: "border-gray-700 hover:border-[#ff6600]/50"
)}
onClick={() => setAccessType('private')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">🔒</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Private Property - Permission Needed</h4>
<p className="text-sm text-gray-400">Backyard ramps, private land, gated spots</p>
</div>
{accessType === 'private' && (
<div className="w-6 h-6 rounded-full bg-[#ff6600] flex items-center justify-center">
<span className="text-white text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Ask Locals */}
<Card
className={cn(
"cursor-pointer transition-all border-2",
accessType === 'locals'
? "bg-[#ccff00]/10 border-[#ccff00] ring-2 ring-[#ccff00]/50"
: "border-gray-700 hover:border-[#ccff00]/50"
)}
onClick={() => setAccessType('locals')}
>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-2xl">👥</div>
<div className="flex-1">
<h4 className="font-bold text-white mb-1">Ask Locals First</h4>
<p className="text-sm text-gray-400">Respect the spot, don't blow it out</p>
</div>
{accessType === 'locals' && (
<div className="w-6 h-6 rounded-full bg-[#ccff00] flex items-center justify-center">
<span className="text-black text-xs">✓</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Contact Info for Private/Locals */}
{(accessType === 'private' || accessType === 'locals') && (
<div className="space-y-2">
<Label htmlFor="contact">Contact Information</Label>
<Input
id="contact"
placeholder="e.g., Phone number, Instagram, email..."
value={contactInfo}
onChange={(e) => setContactInfo(e.target.value)}
className="bg-black border-[#ff6600]/30"
/>
<p className="text-xs text-gray-400">
{accessType === 'private'
? 'How should people contact you for permission?'
: 'How should people reach out to ask about the spot?'}
</p>
</div>
)}
{/* Spot Category (required, mutually exclusive) */}
<div className="space-y-3">
<Label className="text-base font-bold">Spot Type *</Label>
<div className="grid grid-cols-2 gap-2">
{spotCategories.map((category) => (
<Card
key={category.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
selectedCategory === category.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => handleCategoryChange(category.value)}
>
<CardContent className="p-3 sm:p-4 text-center relative">
<div className="font-bold text-sm sm:text-base">{category.label}</div>
<div className="text-xs text-gray-400 mt-1">{category.description}</div>
{selectedCategory === category.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Shelter / weather protection */}
<div className="space-y-3">
<Label className="text-base font-bold">Shelter</Label>
<p className="text-xs text-gray-400 -mt-1">
Is this spot exposed to the weather? Helps skaters find dry spots when it rains.
</p>
<div className="grid grid-cols-2 gap-2">
{shelterOptions.map((option) => (
<Card
key={option.value}
className={cn(
"cursor-pointer transition-all border-2 touch-manipulation active:scale-[0.98]",
shelter === option.value
? "bg-[#ff0040]/10 border-[#ff0040] ring-2 ring-[#ff0040]/50"
: "border-gray-700 hover:border-[#ff0040]/50"
)}
onClick={() => setShelter(option.value)}
>
<CardContent className="p-3 sm:p-4 relative">
<div className="flex items-start gap-2.5">
<div className="text-xl leading-none mt-0.5">{option.emoji}</div>
<div className="flex-1 min-w-0">
<div className="font-bold text-sm sm:text-base text-white">{option.label}</div>
<div className="text-xs text-gray-400 mt-0.5">{option.description}</div>
</div>
</div>
{shelter === option.value && (
<div className="absolute top-1 right-1 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-[#ff0040] flex items-center justify-center">
<Check className="h-2.5 w-2.5 sm:h-3 sm:w-3 text-white" />
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Freeform Tags */}
<div className="space-y-3">
<Label className="text-base font-bold">Tags</Label>
<p className="text-xs text-gray-400 -mt-1">
Add tags for features, surfaces, vibes (max {MAX_TAGS})
</p>
<TagInput
tags={tags}
onChange={setTags}
placeholder="Type to search or add tags..."
maxTags={MAX_TAGS}
/>
</div>
{/* Images */}
<div className="space-y-2">
<Label>Photos</Label>
<div className="grid grid-cols-3 gap-4">
{images.map((url) => (
<div key={url} className="relative aspect-video rounded-lg overflow-hidden bg-muted">
<img src={url} alt="Spot" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => removeImage(url)}
className="absolute top-1 right-1 p-1 rounded-full bg-background/80 hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
))}
<label className="aspect-video rounded-lg border-2 border-dashed flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 transition-colors">
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<>
<Upload className="h-6 w-6 text-muted-foreground mb-1" />
<span className="text-xs text-muted-foreground">Upload</span>
</>
)}
</label>
</div>
</div>
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<MapPin className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}