SkateSpots/src/pages/CreateSpotPage.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

785 lines
31 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, 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, shelterOptions, DEFAULT_SHELTER, SHELTERED_VALUES, type ShelterType } 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 [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
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()]);
}
// Shelter / weather-protection level. Always record the explicit value so
// the spot detail + map can show it; additionally add a queryable `t` tag
// for sheltered spots (indoor/covered/partial) so "dry spots" filtering and
// Sasquatch search work at the relay level. Outdoor is the implicit default
// and adds no `t` tag (keeps the common case from tagging everything).
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]);
}
// 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>
{/* 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>{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>
);
}