CreateSpotPage: - Spot type is marked required (*) and drives the quality meter, but submit never validated it — a spot could be published with no type. Now validated with a clear toast, matching the title/coordinate checks. - Submit was allowed while the Quick Snap photo was still uploading in the background, which would publish the spot WITHOUT the photo the user just took. Submit is now blocked (with an "Uploading photo…" button state and a reassuring toast) until the upload settles. - Removed a leftover console.log of the full event payload. EditSpotPage: - Same missing spot-type validation (also marked * in its UI) — added the same guard for consistency. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
737 lines
28 KiB
TypeScript
737 lines
28 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useSeoMeta } from '@unhead/react';
|
||
import { useNavigate, useSearchParams } 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 { useNostrPublish } from '@/hooks/useNostrPublish';
|
||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||
import { useToast } from '@/hooks/useToast';
|
||
import { useTranslation } from '@/contexts/LanguageContext';
|
||
import { LoginArea } from '@/components/auth/LoginArea';
|
||
import { QualityMeter } from '@/components/QualityMeter';
|
||
import { TagInput } from '@/components/TagInput';
|
||
import { SKATE_SPOT_KIND } from '@/lib/types';
|
||
import { spotCategories, MAX_TAGS } from '@/lib/skateTags';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
MapPin,
|
||
Upload,
|
||
X,
|
||
Loader2,
|
||
ArrowLeft,
|
||
Check,
|
||
Camera,
|
||
} from 'lucide-react';
|
||
import { CameraCapture } from '@/components/CameraCapture';
|
||
import { Link } from 'react-router-dom';
|
||
|
||
|
||
|
||
export default function CreateSpotPage() {
|
||
const { t } = useTranslation();
|
||
|
||
useSeoMeta({
|
||
title: 'Add a Skate Spot - SkateSpots',
|
||
description: 'Share a new skateboarding spot with the community.',
|
||
});
|
||
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
const { user } = useCurrentUser();
|
||
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 [contactInfo, setContactInfo] = useState('');
|
||
const [images, setImages] = useState<string[]>([]);
|
||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||
const [showCamera, setShowCamera] = useState(false);
|
||
// True while the Quick Snap photo is uploading in the background after landing
|
||
// on this form.
|
||
const [isUploadingQuickPhoto, setIsUploadingQuickPhoto] = useState(false);
|
||
|
||
// Pre-fill coordinates from URL params (from map click)
|
||
useEffect(() => {
|
||
const lat = searchParams.get('lat');
|
||
const lng = searchParams.get('lng');
|
||
if (lat && lng) {
|
||
setLatitude(lat);
|
||
setLongitude(lng);
|
||
}
|
||
|
||
// Check for quick capture image from map page
|
||
const quickCapture = searchParams.get('quickCapture');
|
||
if (quickCapture === 'true') {
|
||
// Backward-compat: an already-uploaded URL (old flow).
|
||
const capturedImage = sessionStorage.getItem('quickCaptureImage');
|
||
if (capturedImage) {
|
||
setImages([capturedImage]);
|
||
sessionStorage.removeItem('quickCaptureImage');
|
||
}
|
||
|
||
// New Quick Snap flow: a raw photo (data URL) stashed by the map page.
|
||
// We navigate here immediately for speed, then upload the photo HERE so a
|
||
// slow/failed Blossom upload never strands the user before the form.
|
||
const pendingPhoto = sessionStorage.getItem('quickCapturePendingPhoto');
|
||
if (pendingPhoto) {
|
||
sessionStorage.removeItem('quickCapturePendingPhoto');
|
||
(async () => {
|
||
setIsUploadingQuickPhoto(true);
|
||
try {
|
||
const res = await fetch(pendingPhoto);
|
||
const blob = await res.blob();
|
||
const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||
const uploadTags = await uploadFile(file);
|
||
const url = uploadTags[0]?.[1];
|
||
if (url) {
|
||
setImages((prev) => (prev.includes(url) ? prev : [...prev, url]));
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Photo upload failed',
|
||
description:
|
||
error instanceof Error ? error.message : 'Could not upload the photo. You can add one below.',
|
||
variant: 'destructive',
|
||
});
|
||
} finally {
|
||
setIsUploadingQuickPhoto(false);
|
||
}
|
||
})();
|
||
}
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [searchParams]);
|
||
|
||
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',
|
||
});
|
||
}
|
||
|
||
// Clear the input
|
||
e.target.value = '';
|
||
};
|
||
|
||
// Handle camera capture
|
||
const handleCameraCapture = async (file: File) => {
|
||
try {
|
||
const tags = await uploadFile(file);
|
||
const url = tags[0]?.[1];
|
||
if (url) {
|
||
setImages((prev) => [...prev, url]);
|
||
setShowCamera(false);
|
||
toast({
|
||
title: 'Photo captured!',
|
||
description: 'Your photo has been uploaded successfully.',
|
||
});
|
||
}
|
||
} catch (error) {
|
||
toast({
|
||
title: 'Upload failed',
|
||
description: error instanceof Error ? error.message : 'Failed to upload photo',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
const removeImage = (url: string) => {
|
||
setImages((prev) => prev.filter((img) => img !== url));
|
||
};
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
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;
|
||
}
|
||
|
||
// Don't publish while the Quick Snap photo is still uploading — otherwise the
|
||
// spot would go out without the photo the user just took.
|
||
if (isUploadingQuickPhoto) {
|
||
toast({
|
||
title: 'Hang tight',
|
||
description: 'Your photo is still uploading. It’ll be attached in a second.',
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Generate geohash from coordinates (7 chars = ~153m precision)
|
||
const geohash = ngeohash.encode(lat, lng, 7);
|
||
|
||
// Create unique identifier for addressable event
|
||
const identifier = title
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.slice(0, 50) + '-' + Date.now().toString(36);
|
||
|
||
// Reverse geocode to get structured city/country tags
|
||
// This powers the CVM server's city/country filtering
|
||
let cityTag = '';
|
||
let countryTag = '';
|
||
let locationStr = location.trim();
|
||
try {
|
||
const geoResp = await fetch(
|
||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&zoom=10`,
|
||
{ headers: { 'Accept-Language': 'en', 'User-Agent': 'SkateSpots/1.0' } }
|
||
);
|
||
if (geoResp.ok) {
|
||
const geoData = await geoResp.json();
|
||
const addr = geoData.address ?? {};
|
||
cityTag = addr.city ?? addr.town ?? addr.village ?? addr.county ?? '';
|
||
countryTag = addr.country ?? '';
|
||
// If user didn't fill in location, auto-populate from geocoding
|
||
if (!locationStr && cityTag) {
|
||
locationStr = [cityTag, addr.state, countryTag].filter(Boolean).join(', ');
|
||
}
|
||
}
|
||
} catch {
|
||
// Geocoding is best-effort — don't block spot creation
|
||
}
|
||
|
||
// Build event tags
|
||
const eventTags: string[][] = [
|
||
['d', identifier], // Required for addressable events
|
||
['title', title.trim()],
|
||
['g', geohash],
|
||
['t', 'skatespot'], // Primary identifier for spot events
|
||
['access', accessType],
|
||
['lat', lat.toString()],
|
||
['lon', lng.toString()],
|
||
];
|
||
|
||
if (locationStr) {
|
||
eventTags.push(['location', locationStr]);
|
||
}
|
||
|
||
// Structured city/country tags — critical for CVM server search
|
||
if (cityTag) {
|
||
eventTags.push(['city', cityTag]);
|
||
}
|
||
if (countryTag) {
|
||
eventTags.push(['country', countryTag]);
|
||
}
|
||
|
||
if (contactInfo.trim()) {
|
||
eventTags.push(['contact', contactInfo.trim()]);
|
||
}
|
||
|
||
// 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]);
|
||
}
|
||
|
||
// Add images
|
||
for (const imageUrl of images) {
|
||
// Use imeta tag for NIP-92 compatibility
|
||
eventTags.push(['imeta', `url ${imageUrl}`, 'm image/jpeg']);
|
||
}
|
||
|
||
// Add alt tag for NIP-31 (human-readable description)
|
||
eventTags.push(['alt', `Skate spot: ${title.trim()}`]);
|
||
|
||
await createEvent({
|
||
kind: SKATE_SPOT_KIND,
|
||
content: description.trim(),
|
||
tags: eventTags,
|
||
});
|
||
|
||
toast({
|
||
title: 'Spot added!',
|
||
description: 'Your skate spot has been published to the network.',
|
||
});
|
||
|
||
// Navigate to spot detail using pubkey and identifier
|
||
navigate(`/spot/${user?.pubkey}/${identifier}`);
|
||
} catch (error) {
|
||
console.error('Failed to create spot:', error);
|
||
toast({
|
||
title: 'Failed to publish',
|
||
description: error instanceof Error ? error.message : 'Something went wrong. Check console for details.',
|
||
variant: 'destructive',
|
||
});
|
||
}
|
||
};
|
||
|
||
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">{t('errors.loginRequired')}</h2>
|
||
<p className="text-muted-foreground mb-6">
|
||
{t('createSpot.loginToAdd')}
|
||
</p>
|
||
<LoginArea className="justify-center" />
|
||
</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="/map" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
{t('createSpot.backToMap')}
|
||
</Link>
|
||
<h1 className="text-3xl font-black uppercase neon-red" style={{
|
||
fontFamily: 'Impact, "Arial Black", sans-serif'
|
||
}}>{t('createSpot.title')}</h1>
|
||
<p className="text-gray-400 mt-1">
|
||
{t('createSpot.subtitle')}
|
||
</p>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<Card className="bg-black/90 border-2 border-[#ff0040]/30">
|
||
<CardHeader>
|
||
<CardTitle className="text-[#ff0040] uppercase font-black">{t('spots.spotDetails')}</CardTitle>
|
||
<CardDescription className="text-gray-400">
|
||
{t('createSpot.requiredFields')}
|
||
</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">{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>
|
||
|
||
{/* 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>{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="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>
|
||
))}
|
||
{/* Quick Snap photo uploading in the background */}
|
||
{isUploadingQuickPhoto && (
|
||
<div className="relative aspect-video rounded-lg overflow-hidden bg-muted flex flex-col items-center justify-center border-2 border-dashed border-[#ccff00]/50">
|
||
<Loader2 className="h-6 w-6 animate-spin text-[#ccff00] mb-1" />
|
||
<span className="text-xs text-[#ccff00] font-bold">Uploading photo…</span>
|
||
</div>
|
||
)}
|
||
{/* Camera capture button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCamera(true)}
|
||
disabled={isUploading}
|
||
className="aspect-video rounded-lg border-2 border-dashed border-[#ff0040]/50 flex flex-col items-center justify-center cursor-pointer hover:bg-[#ff0040]/10 hover:border-[#ff0040] transition-colors"
|
||
>
|
||
<Camera className="h-6 w-6 text-[#ff0040] mb-1" />
|
||
<span className="text-xs text-[#ff0040] font-bold">Take Photo</span>
|
||
</button>
|
||
{/* File upload button */}
|
||
<label className="aspect-video rounded-lg border-2 border-dashed border-gray-600 flex flex-col items-center justify-center cursor-pointer hover:bg-muted/50 hover:border-gray-500 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">{t('createSpot.uploadPhotos')}</span>
|
||
</>
|
||
)}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Camera Capture Dialog */}
|
||
<CameraCapture
|
||
open={showCamera}
|
||
onOpenChange={setShowCamera}
|
||
onCapture={handleCameraCapture}
|
||
isUploading={isUploading}
|
||
/>
|
||
|
||
{/* Quality Meter */}
|
||
<QualityMeter
|
||
type="spot"
|
||
hasLocation={!!(latitude && longitude)}
|
||
hasPhotos={images.length > 0}
|
||
photoCount={images.length}
|
||
hasDescription={description.trim().length > 0}
|
||
descriptionLength={description.trim().length}
|
||
hasSpotType={!!selectedCategory}
|
||
/>
|
||
|
||
{/* Submit */}
|
||
<Button
|
||
type="submit"
|
||
className="w-full"
|
||
disabled={isPublishing || isUploading || isUploadingQuickPhoto}
|
||
>
|
||
{isPublishing ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
{t('createSpot.publishing')}
|
||
</>
|
||
) : isUploadingQuickPhoto ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
Uploading photo…
|
||
</>
|
||
) : (
|
||
<>
|
||
<MapPin className="h-4 w-4 mr-2" />
|
||
{t('home.addSpot')}
|
||
</>
|
||
)}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</form>
|
||
</div>
|
||
</Layout>
|
||
);
|
||
}
|