The chat's data layer (useCVMClient) queries Nostr relays directly from the browser — the CVM/ContextVM server round-trip was removed earlier for speed. But the UI and system prompt still claimed data came "from the CVM server", which is no longer true. Aligned every user-facing claim with reality: - System prompt: reframed "CVM Tools / MUST route through CVM" as "Live data tools that query the Nostr relays directly". Tool-calling behaviour unchanged. - Streaming status: "Querying CVM for X" → "Searching the relays for X". - Tool-failure message: no longer blames a non-existent "CVM server"; now correctly frames it as relays being slow, with a retry nudge. - Empty results: stop asserting "not added yet" as fact — an empty result can also mean slow relays or a misspelled place. Now offers retry + add-a-spot, and names the place searched. Applied to the local formatter, the system prompt rules, and the second-pass AI instruction. - Chat footer: "Powered by ContextVM & Nostr Relays Data" → "Live data from Nostr relays" (links the primary community relay). - About page credit: "AI powered by ContextVM" → "Trust & crew chat via ContextVM" (its real role: Relatr trust scores + encrypted crew chat). - Owner CVM health panel: clarified it probes the standalone MCP server and that the in-app chat does not depend on it. - Updated stale internal comments in SasquatchGuide + CreateSpotPage. ContextVM references that ARE accurate (Relatr trust scores, crew-chat Cordn coordinator) were left intact. Build clean, no console errors. 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
|
||
// These power Sasquatch's city/country filtering (relay queries)
|
||
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 Sasquatch relay 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>
|
||
);
|
||
}
|