- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import ngeohash from 'ngeohash';
|
|
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 { useTranslation } from '@/contexts/language';
|
|
import { LoginArea } from '@/components/auth/LoginArea';
|
|
import { PhotoDropzone } from '@/components/PhotoDropzone';
|
|
import { TagInput } from '@/components/TagInput';
|
|
import { SKATE_SPOT_KIND } from '@/lib/types';
|
|
import { reverseGeocodeRegions, pushRegionTags } from '@/lib/geocodeRegions';
|
|
import { spotCategories, MAX_TAGS, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } from '@/lib/skateTags';
|
|
import { SPOT_TAG_CATEGORY_KEYS, SPOT_TAG_CATEGORY_DESC_KEYS, SHELTER_KEYS, SHELTER_DESC_KEYS } from '@/lib/labelKeys';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
MapPin,
|
|
X,
|
|
Loader2,
|
|
ArrowLeft,
|
|
AlertTriangle,
|
|
Check,
|
|
} from 'lucide-react';
|
|
import { Link } from 'react-router-dom';
|
|
import NotFound from './NotFound';
|
|
|
|
function EditSpotSkeleton() {
|
|
return (
|
|
<>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function EditSpotPage() {
|
|
const { t } = useTranslation();
|
|
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 ? `${t('spots.editSpot')} ${spot.title} - SkateSpots` : `${t('spots.editSpot')} - SkateSpots`,
|
|
description: t('spotUi.editSpotSeoDesc'),
|
|
});
|
|
|
|
// 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: t('spotUi.geolocationNotSupported'),
|
|
description: t('spotUi.geolocationNotSupportedDesc'),
|
|
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: t('spotUi.locationFound'),
|
|
description: t('spotUi.locationFoundDesc'),
|
|
});
|
|
},
|
|
(error) => {
|
|
setIsGettingLocation(false);
|
|
toast({
|
|
title: t('spotUi.locationError'),
|
|
description: error.message,
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
// Upload one or more selected/dropped photos, in sequence.
|
|
const uploadImageFiles = async (files: File[]) => {
|
|
if (files.length === 0) return;
|
|
let added = 0;
|
|
try {
|
|
for (const file of files) {
|
|
const tags = await uploadFile(file);
|
|
const url = tags[0]?.[1];
|
|
if (url) {
|
|
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
|
added += 1;
|
|
}
|
|
}
|
|
if (added > 0) {
|
|
toast({
|
|
title: added > 1 ? t('spotUi.photosUploaded', { count: added }) : t('spotUi.imageUploaded'),
|
|
description:
|
|
added > 1 ? t('spotUi.photosAdded') : t('spotUi.imageUploadedDesc'),
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.uploadFailed'),
|
|
description: error instanceof Error ? error.message : t('spotUi.failedToUploadImage'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
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: t('spotUi.titleRequired'),
|
|
description: t('spotUi.titleRequiredDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!latitude || !longitude) {
|
|
toast({
|
|
title: t('spotUi.locationRequired'),
|
|
description: t('spotUi.locationRequiredDesc'),
|
|
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: t('spotUi.invalidCoordinates'),
|
|
description: t('spotUi.invalidCoordinatesDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!selectedCategory) {
|
|
toast({
|
|
title: t('spotUi.spotTypeRequired'),
|
|
description: t('spotUi.spotTypeRequiredDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Generate geohash from coordinates (7 chars = ~153m precision)
|
|
const geohash = ngeohash.encode(lat, lng, 7);
|
|
|
|
// Best-effort reverse geocode → city/country/country_code. Re-run on edit
|
|
// so moving a spot updates its region, AND so a legacy spot that has no
|
|
// tags gets them the next time it's saved. (This page rebuilds tags from
|
|
// scratch, so without this it silently DROPPED any existing region tags.)
|
|
const geo = await reverseGeocodeRegions(lat, lng);
|
|
|
|
// 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', t('spotUi.altSkateSpot', { title: title.trim() })],
|
|
];
|
|
pushRegionTags(eventTags, geo);
|
|
|
|
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: t('spotUi.spotUpdated'),
|
|
description: t('spotUi.spotUpdatedDesc'),
|
|
});
|
|
|
|
navigate(`/spot/${user?.pubkey}/${spot.identifier}`);
|
|
} catch (error) {
|
|
toast({
|
|
title: t('spotUi.failedToUpdate'),
|
|
description: error instanceof Error ? error.message : t('spotUi.somethingWentWrong'),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <EditSpotSkeleton />;
|
|
}
|
|
|
|
if (error || !spot) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
// Check if user is the owner
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<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">{t('spotUi.loginRequiredTitle')}</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
{t('spotUi.loginToEditSpot')}
|
|
</p>
|
|
<LoginArea className="justify-center" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (user.pubkey !== spot.pubkey) {
|
|
return (
|
|
<>
|
|
<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">{t('spotUi.accessDeniedTitle')}</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
{t('spotUi.accessDeniedDesc')}
|
|
</p>
|
|
<Link to={`/spot/${spot.pubkey}/${spot.identifier}`}>
|
|
<Button variant="outline">{t('spotUi.backToSpot')}</Button>
|
|
</Link>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<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" />
|
|
{t('spotUi.backToSpot')}
|
|
</Link>
|
|
<h1 className="text-3xl font-bold">{t('spots.editSpot')}</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
{t('spotUi.updateDetailsFor', { title: spot.title })}
|
|
</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('spots.spotDetails')}</CardTitle>
|
|
<CardDescription>
|
|
{t('spotUi.updateSpotDesc')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Title */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">{t('spots.spotName')} *</Label>
|
|
<Input
|
|
id="title"
|
|
placeholder={t('createSpot.namePlaceholder')}
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">{t('spots.description')}</Label>
|
|
<Textarea
|
|
id="description"
|
|
placeholder={t('createSpot.descriptionPlaceholder')}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
|
|
{/* Location Address */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="location">{t('spots.address')}</Label>
|
|
<Input
|
|
id="location"
|
|
placeholder={t('createSpot.addressPlaceholder')}
|
|
value={location}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Coordinates */}
|
|
<div className="space-y-2">
|
|
<Label>{t('createSpot.coordinates')} *</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Input
|
|
placeholder={t('createSpot.latitudePlaceholder')}
|
|
value={latitude}
|
|
onChange={(e) => setLatitude(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Input
|
|
placeholder={t('createSpot.longitudePlaceholder')}
|
|
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" />
|
|
{t('createSpot.gettingLocation')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-4 w-4 mr-2" />
|
|
{t('createSpot.useCurrentLocation')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Access Type */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-bold">{t('createSpot.accessType')} *</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">{t('createSpot.publicTitle')}</h4>
|
|
<p className="text-sm text-gray-400">{t('createSpot.publicDesc')}</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">{t('createSpot.privateTitle')}</h4>
|
|
<p className="text-sm text-gray-400">{t('createSpot.privateDesc')}</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">{t('createSpot.localsTitle')}</h4>
|
|
<p className="text-sm text-gray-400">{t('createSpot.localsDesc')}</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">{t('createSpot.contactInfo')}</Label>
|
|
<Input
|
|
id="contact"
|
|
placeholder={t('createSpot.contactPlaceholder')}
|
|
value={contactInfo}
|
|
onChange={(e) => setContactInfo(e.target.value)}
|
|
className="bg-black border-[#ff6600]/30"
|
|
/>
|
|
<p className="text-xs text-gray-400">
|
|
{accessType === 'private'
|
|
? t('createSpot.contactHintPrivate')
|
|
: t('createSpot.contactHintLocals')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Spot Category (required, mutually exclusive) */}
|
|
<div className="space-y-3">
|
|
<Label className="text-base font-bold">{t('spots.spotType')} *</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">{SPOT_TAG_CATEGORY_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_KEYS[category.value]) : category.label}</div>
|
|
<div className="text-xs text-gray-400 mt-1">{SPOT_TAG_CATEGORY_DESC_KEYS[category.value] ? t(SPOT_TAG_CATEGORY_DESC_KEYS[category.value]) : 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">{t('spotUi.shelter')}</Label>
|
|
<p className="text-xs text-gray-400 -mt-1">
|
|
{t('spotUi.shelterHint')}
|
|
</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">{SHELTER_KEYS[option.value] ? t(SHELTER_KEYS[option.value]) : option.label}</div>
|
|
<div className="text-xs text-gray-400 mt-0.5">{SHELTER_DESC_KEYS[option.value] ? t(SHELTER_DESC_KEYS[option.value]) : 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">{t('spotUi.tags')}</Label>
|
|
<p className="text-xs text-gray-400 -mt-1">
|
|
{t('spotUi.tagsHint', { max: MAX_TAGS })}
|
|
</p>
|
|
<TagInput
|
|
tags={tags}
|
|
onChange={setTags}
|
|
placeholder={t('spotUi.typeToSearchTags')}
|
|
maxTags={MAX_TAGS}
|
|
/>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Images */}
|
|
<div className="space-y-2">
|
|
<Label>{t('spots.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={t('spotUi.spotPhotoAlt')} 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>
|
|
))}
|
|
<PhotoDropzone
|
|
onFiles={uploadImageFiles}
|
|
uploading={isUploading}
|
|
label={t('createSpot.uploadPhotos')}
|
|
hint={t('spotUi.dragDropOrClick')}
|
|
className="aspect-video"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Submit */}
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={isPublishing || isUploading}
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
{t('spotUi.saving')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<MapPin className="h-4 w-4 mr-2" />
|
|
{t('spotUi.saveChanges')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|