Files
thrilltrack-explorer/src/components/admin/LocationSearch.tsx
pac7 13a4d8f64c Improve error handling and display for searches and uploads
Enhance user feedback by displaying search errors, refine photo submission fetching, add rate limiting cleanup logic, improve image upload cleanup, and strengthen moderator permission checks.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2741d09b-80fb-4f0a-bfd6-ababb2ac4bfc
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
2025-10-08 19:55:55 +00:00

277 lines
9.2 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { supabase } from '@/integrations/supabase/client';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { MapPin, Loader2, X } from 'lucide-react';
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
interface LocationResult {
place_id: number;
display_name: string;
lat: string;
lon: string;
address: {
city?: string;
town?: string;
village?: string;
state?: string;
country?: string;
postcode?: string;
};
}
interface SelectedLocation {
name: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string; // Full OSM display name for reference
}
interface LocationSearchProps {
onLocationSelect: (location: SelectedLocation) => void;
initialLocationId?: string;
className?: string;
}
export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps) {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
const [showResults, setShowResults] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 500);
// Load initial location if editing
useEffect(() => {
if (initialLocationId) {
loadInitialLocation(initialLocationId);
}
}, [initialLocationId]);
const loadInitialLocation = async (locationId: string) => {
const { data, error } = await supabase
.from('locations')
.select('*')
.eq('id', locationId)
.maybeSingle();
if (data && !error) {
setSelectedLocation({
name: data.name,
city: data.city || undefined,
state_province: data.state_province || undefined,
country: data.country,
postal_code: data.postal_code || undefined,
latitude: parseFloat(data.latitude?.toString() || '0'),
longitude: parseFloat(data.longitude?.toString() || '0'),
timezone: data.timezone || undefined,
display_name: data.name, // Use name as display for existing locations
});
}
};
const searchLocations = useCallback(async (query: string) => {
if (!query || query.length < 3) {
setResults([]);
setSearchError(null);
return;
}
setIsSearching(true);
setSearchError(null);
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`,
{
headers: {
'User-Agent': 'ThemeParkDatabase/1.0',
},
}
);
// Check if response is OK and content-type is JSON
if (!response.ok) {
const errorMsg = `Location search failed (${response.status}). Please try again.`;
console.error('OpenStreetMap API error:', response.status);
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const errorMsg = 'Invalid response from location service. Please try again.';
console.error('Invalid response format from OpenStreetMap');
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
return;
}
const data = await response.json();
setResults(data);
setShowResults(true);
setSearchError(null);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Failed to search locations. Please check your connection.';
console.error('Error searching locations:', error);
setSearchError(errorMsg);
setResults([]);
setShowResults(false);
} finally {
setIsSearching(false);
}
}, []);
useEffect(() => {
if (debouncedSearch) {
searchLocations(debouncedSearch);
} else {
setResults([]);
setShowResults(false);
}
}, [debouncedSearch, searchLocations]);
const handleSelectResult = async (result: LocationResult) => {
const latitude = parseFloat(result.lat);
const longitude = parseFloat(result.lon);
const city = result.address.city || result.address.town || result.address.village;
const locationName = city
? `${city}, ${result.address.state || ''} ${result.address.country}`.trim()
: result.display_name;
// Build location data object (no database operations)
const locationData: SelectedLocation = {
name: locationName,
city: city || undefined,
state_province: result.address.state || undefined,
country: result.address.country || '',
postal_code: result.address.postcode || undefined,
latitude,
longitude,
timezone: undefined, // Will be set by server during approval if needed
display_name: result.display_name,
};
setSelectedLocation(locationData);
setSearchQuery('');
setResults([]);
setShowResults(false);
onLocationSelect(locationData);
};
const handleClear = () => {
setSelectedLocation(null);
setSearchQuery('');
setResults([]);
setShowResults(false);
};
return (
<div className={className}>
{!selectedLocation ? (
<div className="space-y-2">
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search for a location (city, address, landmark...)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{searchError && (
<div className="text-sm text-destructive mt-1">
{searchError}
</div>
)}
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
<div className="divide-y">
{results.map((result) => (
<button
type="button"
key={result.place_id}
onClick={() => handleSelectResult(result)}
className="w-full text-left p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.display_name}</p>
<p className="text-xs text-muted-foreground">
{result.lat}, {result.lon}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
{showResults && results.length === 0 && !isSearching && !searchError && (
<div className="text-sm text-muted-foreground mt-1">
No locations found. Try a different search term.
</div>
)}
</div>
) : (
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<MapPin className="h-5 w-5 text-primary mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium">{selectedLocation.name}</p>
<div className="text-sm text-muted-foreground space-y-1 mt-1">
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
<p>Country: {selectedLocation.country}</p>
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
<p className="text-xs">
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
</p>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
<ParkLocationMap
latitude={selectedLocation.latitude}
longitude={selectedLocation.longitude}
parkName={selectedLocation.name}
className="h-48"
/>
</div>
)}
</div>
);
}