diff --git a/src/App.tsx b/src/App.tsx index 2027bce0..5fdb92ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,12 +14,17 @@ import RideDetail from "./pages/RideDetail"; import Rides from "./pages/Rides"; import Manufacturers from "./pages/Manufacturers"; import ManufacturerDetail from "./pages/ManufacturerDetail"; +import ManufacturerRides from "./pages/ManufacturerRides"; +import ManufacturerModels from "./pages/ManufacturerModels"; import Designers from "./pages/Designers"; import DesignerDetail from "./pages/DesignerDetail"; +import DesignerRides from "./pages/DesignerRides"; import ParkOwners from "./pages/ParkOwners"; import PropertyOwnerDetail from "./pages/PropertyOwnerDetail"; +import OwnerParks from "./pages/OwnerParks"; import Operators from "./pages/Operators"; import OperatorDetail from "./pages/OperatorDetail"; +import OperatorParks from "./pages/OperatorParks"; import Auth from "./pages/Auth"; import Profile from "./pages/Profile"; import UserSettings from "./pages/UserSettings"; @@ -51,12 +56,17 @@ function AppContent() { } /> } /> } /> + } /> + } /> } /> } /> + } /> } /> } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/admin/LocationSearch.tsx b/src/components/admin/LocationSearch.tsx new file mode 100644 index 00000000..66c397e0 --- /dev/null +++ b/src/components/admin/LocationSearch.tsx @@ -0,0 +1,254 @@ +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([]); + const [isSearching, setIsSearching] = useState(false); + const [selectedLocation, setSelectedLocation] = useState(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([]); + return; + } + + setIsSearching(true); + 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) { + console.error('OpenStreetMap API error:', response.status); + setResults([]); + setShowResults(false); + return; + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + console.error('Invalid response format from OpenStreetMap'); + setResults([]); + setShowResults(false); + return; + } + + const data = await response.json(); + setResults(data); + setShowResults(true); + } catch (error) { + console.error('Error searching locations:', error); + 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 ( +
+ {!selectedLocation ? ( +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> + {isSearching && ( + + )} +
+ + {showResults && results.length > 0 && ( + +
+ {results.map((result) => ( + + ))} +
+
+ )} +
+ ) : ( +
+ +
+
+ +
+

{selectedLocation.name}

+
+ {selectedLocation.city &&

City: {selectedLocation.city}

} + {selectedLocation.state_province &&

State/Province: {selectedLocation.state_province}

} +

Country: {selectedLocation.country}

+ {selectedLocation.postal_code &&

Postal Code: {selectedLocation.postal_code}

} +

+ Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)} +

+
+
+
+ +
+
+ + +
+ )} +
+ ); +} diff --git a/src/components/admin/ParkForm.tsx b/src/components/admin/ParkForm.tsx index 1825521b..eb40dff2 100644 --- a/src/components/admin/ParkForm.tsx +++ b/src/components/admin/ParkForm.tsx @@ -18,6 +18,7 @@ import { Combobox } from '@/components/ui/combobox'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData'; import { useUserRole } from '@/hooks/useUserRole'; +import { LocationSearch } from './LocationSearch'; const parkSchema = z.object({ name: z.string().min(1, 'Park name is required'), @@ -27,6 +28,18 @@ const parkSchema = z.object({ status: z.string().min(1, 'Status is required'), opening_date: z.string().optional(), closing_date: z.string().optional(), + location: z.object({ + name: z.string(), + city: z.string().optional(), + state_province: z.string().optional(), + country: z.string(), + postal_code: z.string().optional(), + latitude: z.number(), + longitude: z.number(), + timezone: z.string().optional(), + display_name: z.string(), + }).optional(), + location_id: z.string().uuid().optional(), website_url: z.string().url().optional().or(z.literal('')), phone: z.string().optional(), email: z.string().email().optional().or(z.literal('')), @@ -118,6 +131,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: status: initialData?.status || 'Operating', opening_date: initialData?.opening_date || '', closing_date: initialData?.closing_date || '', + location_id: (initialData as any)?.location_id || undefined, website_url: initialData?.website_url || '', phone: initialData?.phone || '', email: initialData?.email || '', @@ -281,6 +295,20 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: + {/* Location */} +
+ + { + setValue('location', location); + }} + initialLocationId={watch('location_id')} + /> +

+ Search for the park's location using OpenStreetMap. Location will be created when submission is approved. +

+
+ {/* Operator & Property Owner Selection */}

Operator & Property Owner

diff --git a/src/components/admin/RideForm.tsx b/src/components/admin/RideForm.tsx index f2651f26..2b2431b5 100644 --- a/src/components/admin/RideForm.tsx +++ b/src/components/admin/RideForm.tsx @@ -25,12 +25,9 @@ import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor'; import { CoasterStatsEditor } from './editors/CoasterStatsEditor'; import { FormerNamesEditor } from './editors/FormerNamesEditor'; import { - convertSpeed, - convertDistance, - convertHeight, - convertSpeedToMetric, - convertDistanceToMetric, - convertHeightToMetric, + convertValueToMetric, + convertValueFromMetric, + getDisplayUnit, getSpeedUnit, getDistanceUnit, getHeightUnit @@ -59,9 +56,6 @@ const rideSchema = z.object({ intensity_level: z.string().optional(), drop_height_meters: z.number().optional(), max_g_force: z.number().optional(), - former_names: z.string().optional(), - coaster_stats: z.string().optional(), - technical_specs: z.string().optional(), // Manufacturer and model manufacturer_id: z.string().uuid().optional(), ride_model_id: z.string().uuid().optional(), @@ -200,31 +194,28 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: closing_date: initialData?.closing_date || '', // Convert metric values to user's preferred unit for display height_requirement: initialData?.height_requirement - ? convertHeight(initialData.height_requirement, measurementSystem) + ? convertValueFromMetric(initialData.height_requirement, getDisplayUnit('cm', measurementSystem), 'cm') : undefined, age_requirement: initialData?.age_requirement || undefined, capacity_per_hour: initialData?.capacity_per_hour || undefined, duration_seconds: initialData?.duration_seconds || undefined, max_speed_kmh: initialData?.max_speed_kmh - ? convertSpeed(initialData.max_speed_kmh, measurementSystem) + ? convertValueFromMetric(initialData.max_speed_kmh, getDisplayUnit('km/h', measurementSystem), 'km/h') : undefined, max_height_meters: initialData?.max_height_meters - ? convertDistance(initialData.max_height_meters, measurementSystem) + ? convertValueFromMetric(initialData.max_height_meters, getDisplayUnit('m', measurementSystem), 'm') : undefined, length_meters: initialData?.length_meters - ? convertDistance(initialData.length_meters, measurementSystem) + ? convertValueFromMetric(initialData.length_meters, getDisplayUnit('m', measurementSystem), 'm') : undefined, inversions: initialData?.inversions || undefined, coaster_type: initialData?.coaster_type || undefined, seating_type: initialData?.seating_type || undefined, intensity_level: initialData?.intensity_level || undefined, drop_height_meters: initialData?.drop_height_meters - ? convertDistance(initialData.drop_height_meters, measurementSystem) + ? convertValueFromMetric(initialData.drop_height_meters, getDisplayUnit('m', measurementSystem), 'm') : undefined, max_g_force: initialData?.max_g_force || undefined, - former_names: initialData?.former_names || '', - coaster_stats: initialData?.coaster_stats || '', - technical_specs: initialData?.technical_specs || '', manufacturer_id: initialData?.manufacturer_id || undefined, ride_model_id: initialData?.ride_model_id || undefined, images: { uploaded: [] } @@ -245,52 +236,30 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: ...data, status: dbStatus, height_requirement: data.height_requirement - ? convertHeightToMetric(data.height_requirement, measurementSystem) + ? convertValueToMetric(data.height_requirement, getDisplayUnit('cm', measurementSystem)) : undefined, max_speed_kmh: data.max_speed_kmh - ? convertSpeedToMetric(data.max_speed_kmh, measurementSystem) + ? convertValueToMetric(data.max_speed_kmh, getDisplayUnit('km/h', measurementSystem)) : undefined, max_height_meters: data.max_height_meters - ? convertDistanceToMetric(data.max_height_meters, measurementSystem) + ? convertValueToMetric(data.max_height_meters, getDisplayUnit('m', measurementSystem)) : undefined, length_meters: data.length_meters - ? convertDistanceToMetric(data.length_meters, measurementSystem) + ? convertValueToMetric(data.length_meters, getDisplayUnit('m', measurementSystem)) : undefined, drop_height_meters: data.drop_height_meters - ? convertDistanceToMetric(data.drop_height_meters, measurementSystem) + ? convertValueToMetric(data.drop_height_meters, getDisplayUnit('m', measurementSystem)) : undefined, - // Add structured data from advanced editors - technical_specs: JSON.stringify(technicalSpecs), - coaster_stats: JSON.stringify(coasterStats), - former_names: JSON.stringify(formerNames) + // Pass relational data for proper handling + _technical_specifications: technicalSpecs, + _coaster_statistics: coasterStats, + _name_history: formerNames, + _tempNewManufacturer: tempNewManufacturer, + _tempNewRideModel: tempNewRideModel }; - // Build composite submission if new entities were created - const submissionContent: any = { - ride: metricData, - // Include structured data for relational tables - technical_specifications: technicalSpecs, - coaster_statistics: coasterStats, - name_history: formerNames - }; - - // Add new manufacturer if created - if (tempNewManufacturer) { - submissionContent.new_manufacturer = tempNewManufacturer; - submissionContent.ride.manufacturer_id = null; // Clear since using new - } - - // Add new ride model if created - if (tempNewRideModel) { - submissionContent.new_ride_model = tempNewRideModel; - submissionContent.ride.ride_model_id = null; // Clear since using new - } - - // Pass composite data to parent - await onSubmit({ - ...metricData, - _compositeSubmission: submissionContent - } as any); + // Pass clean data to parent + await onSubmit(metricData as any); toast({ title: isEditing ? "Ride Updated" : "Submission Sent", diff --git a/src/components/admin/RideModelForm.tsx b/src/components/admin/RideModelForm.tsx index 38f20b34..53575065 100644 --- a/src/components/admin/RideModelForm.tsx +++ b/src/components/admin/RideModelForm.tsx @@ -21,7 +21,6 @@ const rideModelSchema = z.object({ category: z.string().min(1, 'Category is required'), ride_type: z.string().min(1, 'Ride type is required'), description: z.string().optional(), - technical_specs: z.string().optional(), images: z.object({ uploaded: z.array(z.object({ url: z.string(), @@ -82,12 +81,19 @@ export function RideModelForm({ category: initialData?.category || '', ride_type: initialData?.ride_type || '', description: initialData?.description || '', - technical_specs: initialData?.technical_specs || '', images: initialData?.images || { uploaded: [] } } }); + const handleFormSubmit = (data: RideModelFormData) => { + // Include relational technical specs + onSubmit({ + ...data, + _technical_specifications: technicalSpecs + } as any); + }; + return ( @@ -101,7 +107,7 @@ export function RideModelForm({
-
+ {/* Basic Information */}
diff --git a/src/components/admin/editors/CoasterStatsEditor.tsx b/src/components/admin/editors/CoasterStatsEditor.tsx index d1c4ccae..9d4ca5bf 100644 --- a/src/components/admin/editors/CoasterStatsEditor.tsx +++ b/src/components/admin/editors/CoasterStatsEditor.tsx @@ -5,6 +5,14 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Card } from "@/components/ui/card"; +import { useUnitPreferences } from "@/hooks/useUnitPreferences"; +import { + convertValueToMetric, + convertValueFromMetric, + detectUnitType, + getMetricUnit, + getDisplayUnit +} from "@/lib/units"; interface CoasterStat { stat_name: string; @@ -38,6 +46,7 @@ export function CoasterStatsEditor({ onChange, categories = DEFAULT_CATEGORIES }: CoasterStatsEditorProps) { + const { preferences } = useUnitPreferences(); const addStat = () => { onChange([ @@ -78,6 +87,26 @@ export function CoasterStatsEditor({ onChange(newStats); }; + // Get display value (convert from metric to user's preferred units) + const getDisplayValue = (stat: CoasterStat): string => { + if (!stat.stat_value || !stat.unit) return String(stat.stat_value || ''); + + const numValue = Number(stat.stat_value); + if (isNaN(numValue)) return String(stat.stat_value); + + const unitType = detectUnitType(stat.unit); + if (unitType === 'unknown') return String(stat.stat_value); + + // stat.unit is the metric unit (e.g., "km/h") + // Get the display unit based on user preference (e.g., "mph" for imperial) + const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system); + + // Convert from metric to display unit + const displayValue = convertValueFromMetric(numValue, displayUnit, stat.unit); + + return String(displayValue); + }; + return (
@@ -129,10 +158,28 @@ export function CoasterStatsEditor({ updateStat(index, 'stat_value', parseFloat(e.target.value) || 0)} + value={getDisplayValue(stat)} + onChange={(e) => { + const inputValue = e.target.value; + const numValue = parseFloat(inputValue); + + if (!isNaN(numValue) && stat.unit) { + // Determine what unit the user is entering (based on their preference) + const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system); + // Convert from user's input unit to metric for storage + const metricValue = convertValueToMetric(numValue, displayUnit); + updateStat(index, 'stat_value', metricValue); + } else { + updateStat(index, 'stat_value', numValue || 0); + } + }} placeholder="0" /> + {stat.unit && detectUnitType(stat.unit) !== 'unknown' && ( +

+ Enter in {getDisplayUnit(stat.unit, preferences.measurement_system)} +

+ )}
diff --git a/src/components/admin/editors/TechnicalSpecsEditor.tsx b/src/components/admin/editors/TechnicalSpecsEditor.tsx index c9569b82..bd3930c8 100644 --- a/src/components/admin/editors/TechnicalSpecsEditor.tsx +++ b/src/components/admin/editors/TechnicalSpecsEditor.tsx @@ -4,6 +4,14 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card } from "@/components/ui/card"; +import { useUnitPreferences } from "@/hooks/useUnitPreferences"; +import { + convertValueToMetric, + convertValueFromMetric, + detectUnitType, + getMetricUnit, + getDisplayUnit +} from "@/lib/units"; interface TechnicalSpec { spec_name: string; @@ -30,6 +38,7 @@ export function TechnicalSpecsEditor({ categories = DEFAULT_CATEGORIES, commonSpecs = [] }: TechnicalSpecsEditorProps) { + const { preferences } = useUnitPreferences(); const addSpec = () => { onChange([ @@ -57,6 +66,26 @@ export function TechnicalSpecsEditor({ onChange(newSpecs); }; + // Get display value (convert from metric to user's preferred units) + const getDisplayValue = (spec: TechnicalSpec): string => { + if (!spec.spec_value || !spec.unit || spec.spec_type !== 'number') return spec.spec_value; + + const numValue = parseFloat(spec.spec_value); + if (isNaN(numValue)) return spec.spec_value; + + const unitType = detectUnitType(spec.unit); + if (unitType === 'unknown') return spec.spec_value; + + // spec.unit is the metric unit (e.g., "km/h") + // Get the display unit based on user preference (e.g., "mph" for imperial) + const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system); + + // Convert from metric to display unit + const displayValue = convertValueFromMetric(numValue, displayUnit, spec.unit); + + return String(displayValue); + }; + const moveSpec = (index: number, direction: 'up' | 'down') => { if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) { return; @@ -107,11 +136,30 @@ export function TechnicalSpecsEditor({
updateSpec(index, 'spec_value', e.target.value)} + value={getDisplayValue(spec)} + onChange={(e) => { + const inputValue = e.target.value; + const numValue = parseFloat(inputValue); + + // If type is number and unit is recognized, convert to metric for storage + if (spec.spec_type === 'number' && spec.unit && !isNaN(numValue)) { + // Determine what unit the user is entering (based on their preference) + const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system); + // Convert from user's input unit to metric for storage + const metricValue = convertValueToMetric(numValue, displayUnit); + updateSpec(index, 'spec_value', String(metricValue)); + } else { + updateSpec(index, 'spec_value', inputValue); + } + }} placeholder="Value" type={spec.spec_type === 'number' ? 'number' : 'text'} /> + {spec.spec_type === 'number' && spec.unit && detectUnitType(spec.unit) !== 'unknown' && ( +

+ Enter in {getDisplayUnit(spec.unit, preferences.measurement_system)} +

+ )}
diff --git a/src/components/homepage/HeroSearch.tsx b/src/components/homepage/HeroSearch.tsx index 18186a68..d0b4407d 100644 --- a/src/components/homepage/HeroSearch.tsx +++ b/src/components/homepage/HeroSearch.tsx @@ -33,14 +33,7 @@ export function HeroSearch() { ]; const handleSearch = () => { - if (!searchTerm.trim()) return; - - const params = new URLSearchParams(); - params.set('q', searchTerm); - if (selectedType !== 'all') params.set('type', selectedType); - if (selectedCountry !== 'all') params.set('country', selectedCountry); - - navigate(`/search?${params.toString()}`); + // Search functionality handled by AutocompleteSearch component in Header }; return ( diff --git a/src/components/lists/UserListManager.tsx b/src/components/lists/UserListManager.tsx index b00ba9a5..b8d2cb4a 100644 --- a/src/components/lists/UserListManager.tsx +++ b/src/components/lists/UserListManager.tsx @@ -96,7 +96,6 @@ export function UserListManager() { description: newListDescription || null, list_type: newListType, is_public: newListIsPublic, - items: [], // Legacy field, will be empty }]) .select() .single(); diff --git a/src/components/moderation/ArrayFieldDiff.tsx b/src/components/moderation/ArrayFieldDiff.tsx new file mode 100644 index 00000000..584b596d --- /dev/null +++ b/src/components/moderation/ArrayFieldDiff.tsx @@ -0,0 +1,218 @@ +import { Badge } from '@/components/ui/badge'; +import { Plus, Minus, Edit, Check } from 'lucide-react'; +import { formatFieldValue } from '@/lib/submissionChangeDetection'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; + +interface ArrayFieldDiffProps { + fieldName: string; + oldArray: any[]; + newArray: any[]; + compact?: boolean; +} + +interface ArrayDiffItem { + type: 'added' | 'removed' | 'modified' | 'unchanged'; + oldValue?: any; + newValue?: any; + index: number; +} + +export function ArrayFieldDiff({ fieldName, oldArray, newArray, compact = false }: ArrayFieldDiffProps) { + const [showUnchanged, setShowUnchanged] = useState(false); + + // Compute array differences + const differences = computeArrayDiff(oldArray || [], newArray || []); + const changedItems = differences.filter(d => d.type !== 'unchanged'); + const unchangedCount = differences.filter(d => d.type === 'unchanged').length; + const totalChanges = changedItems.length; + + if (compact) { + return ( + + + {fieldName} ({totalChanges} changes) + + ); + } + + return ( +
+
+
+ {fieldName} ({differences.length} items, {totalChanges} changed) +
+ {unchangedCount > 0 && ( + + )} +
+ +
+ {differences.map((diff, idx) => { + if (diff.type === 'unchanged' && !showUnchanged) { + return null; + } + + return ( + + ); + })} +
+
+ ); +} + +function ArrayDiffItemDisplay({ diff }: { diff: ArrayDiffItem }) { + const isObject = typeof diff.newValue === 'object' || typeof diff.oldValue === 'object'; + + switch (diff.type) { + case 'added': + return ( +
+ +
+ {isObject ? ( + + ) : ( + + {formatFieldValue(diff.newValue)} + + )} +
+
+ ); + + case 'removed': + return ( +
+ +
+ {isObject ? ( + + ) : ( + + {formatFieldValue(diff.oldValue)} + + )} +
+
+ ); + + case 'modified': + return ( +
+
+ +
+
+ {isObject ? ( + + ) : ( + formatFieldValue(diff.oldValue) + )} +
+
+ {isObject ? ( + + ) : ( + formatFieldValue(diff.newValue) + )} +
+
+
+
+ ); + + case 'unchanged': + return ( +
+ +
+ {isObject ? ( + + ) : ( + formatFieldValue(diff.newValue) + )} +
+
+ ); + } +} + +function ObjectDisplay({ value, className = '' }: { value: any; className?: string }) { + if (!value || typeof value !== 'object') { + return {formatFieldValue(value)}; + } + + return ( +
+ {Object.entries(value).map(([key, val]) => ( +
+ {key.replace(/_/g, ' ')}: + {formatFieldValue(val)} +
+ ))} +
+ ); +} + +/** + * Compute differences between two arrays + */ +function computeArrayDiff(oldArray: any[], newArray: any[]): ArrayDiffItem[] { + const results: ArrayDiffItem[] = []; + const maxLength = Math.max(oldArray.length, newArray.length); + + // Simple position-based comparison + for (let i = 0; i < maxLength; i++) { + const oldValue = i < oldArray.length ? oldArray[i] : undefined; + const newValue = i < newArray.length ? newArray[i] : undefined; + + if (oldValue === undefined && newValue !== undefined) { + // Added + results.push({ type: 'added', newValue, index: i }); + } else if (oldValue !== undefined && newValue === undefined) { + // Removed + results.push({ type: 'removed', oldValue, index: i }); + } else if (!isEqual(oldValue, newValue)) { + // Modified + results.push({ type: 'modified', oldValue, newValue, index: i }); + } else { + // Unchanged + results.push({ type: 'unchanged', oldValue, newValue, index: i }); + } + } + + return results; +} + +/** + * Deep equality check + */ +function isEqual(a: any, b: any): boolean { + if (a === b) return true; + if (a == null || b == null) return a === b; + if (typeof a !== typeof b) return false; + + if (typeof a === 'object') { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => isEqual(item, b[i])); + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + + return keysA.every(key => isEqual(a[key], b[key])); + } + + return false; +} diff --git a/src/components/moderation/EntityEditPreview.tsx b/src/components/moderation/EntityEditPreview.tsx index 9b436461..6a522501 100644 --- a/src/components/moderation/EntityEditPreview.tsx +++ b/src/components/moderation/EntityEditPreview.tsx @@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti const [cardImageUrl, setCardImageUrl] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); + const [isPhotoOperation, setIsPhotoOperation] = useState(false); useEffect(() => { fetchSubmissionItems(); @@ -57,6 +58,15 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti setItemData(firstItem.item_data); setOriginalData(firstItem.original_data); + // Check for photo edit/delete operations + if (firstItem.item_type === 'photo_edit' || firstItem.item_type === 'photo_delete') { + setIsPhotoOperation(true); + if (firstItem.item_type === 'photo_edit') { + setChangedFields(['caption']); + } + return; + } + // Parse changed fields const changed: string[] = []; const data = firstItem.item_data as any; @@ -121,6 +131,63 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti ); } + // Handle photo edit/delete operations + if (isPhotoOperation) { + const isEdit = changedFields.includes('caption'); + return ( +
+
+ + Photo + + + {isEdit ? 'Edit' : 'Delete'} + +
+ + {itemData?.cloudflare_image_url && ( + + + Photo to be modified + + + )} + + {isEdit && ( +
+
+ Old caption: + + {originalData?.caption || No caption} + +
+
+ New caption: + + {itemData?.new_caption || No caption} + +
+
+ )} + + {!isEdit && itemData?.reason && ( +
+ Reason: + {itemData.reason} +
+ )} + +
+ Click "Review Items" for full details +
+
+ ); + } + // Build photos array for modal const photos = []; if (bannerImageUrl) { diff --git a/src/components/moderation/FieldComparison.tsx b/src/components/moderation/FieldComparison.tsx new file mode 100644 index 00000000..c35b13b3 --- /dev/null +++ b/src/components/moderation/FieldComparison.tsx @@ -0,0 +1,191 @@ +import { Badge } from '@/components/ui/badge'; +import { formatFieldName, formatFieldValue } from '@/lib/submissionChangeDetection'; +import type { FieldChange, ImageChange } from '@/lib/submissionChangeDetection'; +import { ArrowRight } from 'lucide-react'; +import { ArrayFieldDiff } from './ArrayFieldDiff'; +import { SpecialFieldDisplay } from './SpecialFieldDisplay'; + +interface FieldDiffProps { + change: FieldChange; + compact?: boolean; +} + +export function FieldDiff({ change, compact = false }: FieldDiffProps) { + const { field, oldValue, newValue, changeType } = change; + + // Check if this is an array field that needs special handling + if (Array.isArray(oldValue) && Array.isArray(newValue)) { + return ( + + ); + } + + // Check if this is a special field type that needs custom rendering + const specialDisplay = SpecialFieldDisplay({ change, compact }); + if (specialDisplay) { + return specialDisplay; + } + + const getChangeColor = () => { + switch (changeType) { + case 'added': return 'text-green-600 dark:text-green-400'; + case 'removed': return 'text-red-600 dark:text-red-400'; + case 'modified': return 'text-amber-600 dark:text-amber-400'; + default: return ''; + } + }; + + if (compact) { + return ( + + {formatFieldName(field)} + + ); + } + + return ( +
+
{formatFieldName(field)}
+ + {changeType === 'added' && ( +
+ + {formatFieldValue(newValue)} +
+ )} + + {changeType === 'removed' && ( +
+ {formatFieldValue(oldValue)} +
+ )} + + {changeType === 'modified' && ( +
+ + {formatFieldValue(oldValue)} + + + + {formatFieldValue(newValue)} + +
+ )} +
+ ); +} + +interface ImageDiffProps { + change: ImageChange; + compact?: boolean; +} + +export function ImageDiff({ change, compact = false }: ImageDiffProps) { + const { type, oldUrl, newUrl } = change; + + if (compact) { + return ( + + {type === 'banner' ? 'Banner' : 'Card'} Image + + ); + } + + // Determine scenario + const isAddition = !oldUrl && newUrl; + const isRemoval = oldUrl && !newUrl; + const isReplacement = oldUrl && newUrl; + + return ( +
+
+ {type === 'banner' ? 'Banner' : 'Card'} Image + {isAddition && (New)} + {isRemoval && (Removed)} + {isReplacement && (Changed)} +
+ +
+ {oldUrl && ( +
+
Before
+ Previous +
+ )} + + {oldUrl && newUrl && ( + + )} + + {newUrl && ( +
+
{isAddition ? 'New Image' : 'After'}
+ New +
+ )} +
+
+ ); +} + +interface LocationDiffProps { + oldLocation: any; + newLocation: any; + compact?: boolean; +} + +export function LocationDiff({ oldLocation, newLocation, compact = false }: LocationDiffProps) { + const formatLocation = (loc: any) => { + if (!loc) return 'None'; + if (typeof loc === 'string') return loc; + if (typeof loc === 'object') { + const parts = [loc.city, loc.state_province, loc.country].filter(Boolean); + return parts.join(', ') || 'Unknown'; + } + return String(loc); + }; + + if (compact) { + return ( + + Location + + ); + } + + return ( +
+
Location
+ +
+ {oldLocation && ( + + {formatLocation(oldLocation)} + + )} + {oldLocation && newLocation && ( + + )} + {newLocation && ( + + {formatLocation(newLocation)} + + )} +
+
+ ); +} diff --git a/src/components/moderation/ItemReviewCard.tsx b/src/components/moderation/ItemReviewCard.tsx index 956f4348..a67f1729 100644 --- a/src/components/moderation/ItemReviewCard.tsx +++ b/src/components/moderation/ItemReviewCard.tsx @@ -5,14 +5,16 @@ import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react'; import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import { useIsMobile } from '@/hooks/use-mobile'; import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay'; +import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; interface ItemReviewCardProps { item: SubmissionItemWithDeps; onEdit: () => void; onStatusChange: (status: 'approved' | 'rejected') => void; + submissionId: string; } -export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardProps) { +export function ItemReviewCard({ item, onEdit, onStatusChange, submissionId }: ItemReviewCardProps) { const isMobile = useIsMobile(); const getItemIcon = () => { @@ -39,74 +41,15 @@ export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardP }; const renderItemPreview = () => { - const data = item.item_data; - - switch (item.item_type) { - case 'park': - return ( -
-

{data.name}

-

{data.description}

-
- {data.park_type && {data.park_type}} - {data.status && {data.status}} -
-
- ); - - case 'ride': - return ( -
-

{data.name}

-

{data.description}

-
- {data.category && {data.category}} - {data.status && {data.status}} -
-
- ); - - case 'manufacturer': - case 'operator': - case 'property_owner': - case 'designer': - return ( -
-

{data.name}

-

{data.description}

- {data.founded_year && ( - Founded {data.founded_year} - )} -
- ); - - case 'ride_model': - return ( -
-

{data.name}

-

{data.description}

-
- {data.category && {data.category}} - {data.ride_type && {data.ride_type}} -
-
- ); - - case 'photo': - return ( -
- {/* Fetch and display from photo_submission_items */} - -
- ); - - default: - return ( -
- No preview available -
- ); - } + // Use detailed view for review manager with photo detection + return ( + + ); }; return ( diff --git a/src/components/moderation/ModerationQueue.tsx b/src/components/moderation/ModerationQueue.tsx index 49060726..6788aee6 100644 --- a/src/components/moderation/ModerationQueue.tsx +++ b/src/components/moderation/ModerationQueue.tsx @@ -14,9 +14,11 @@ import { useAuth } from '@/hooks/useAuth'; import { format } from 'date-fns'; import { PhotoModal } from './PhotoModal'; import { SubmissionReviewManager } from './SubmissionReviewManager'; -import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions'; import { useIsMobile } from '@/hooks/use-mobile'; -import { EntityEditPreview } from './EntityEditPreview'; +import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; +import { SubmissionItemsList } from './SubmissionItemsList'; +import { MeasurementDisplay } from '@/components/ui/measurement-display'; +import { useAdminSettings } from '@/hooks/useAdminSettings'; interface ModerationItem { id: string; @@ -54,6 +56,7 @@ export const ModerationQueue = forwardRef((props, ref) => { const isMobile = useIsMobile(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); + const [isInitialLoad, setIsInitialLoad] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [notes, setNotes] = useState>({}); const [activeEntityFilter, setActiveEntityFilter] = useState('all'); @@ -67,21 +70,28 @@ export const ModerationQueue = forwardRef((props, ref) => { const { isAdmin, isSuperuser } = useUserRole(); const { user } = useAuth(); + // Get admin settings for polling configuration + const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings(); + const refreshMode = getAdminPanelRefreshMode(); + const pollInterval = getAdminPanelPollInterval(); + // Expose refresh method via ref useImperativeHandle(ref, () => ({ refresh: () => { - fetchItems(activeEntityFilter, activeStatusFilter); + fetchItems(activeEntityFilter, activeStatusFilter, false); // Manual refresh shows loading } }), [activeEntityFilter, activeStatusFilter]); - const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => { + const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => { if (!user) { - console.log('Skipping fetch - user not authenticated'); return; } try { - setLoading(true); + // Only show loading on initial load or filter change + if (!silent) { + setLoading(true); + } let reviewStatuses: string[] = []; let submissionStatuses: string[] = []; @@ -325,9 +335,6 @@ export const ModerationQueue = forwardRef((props, ref) => { // Sort by creation date (newest first for better UX) formattedItems.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - - console.log('Formatted items:', formattedItems); - console.log('Photo submissions:', formattedItems.filter(item => item.submission_type === 'photo')); setItems(formattedItems); } catch (error: any) { @@ -343,46 +350,36 @@ export const ModerationQueue = forwardRef((props, ref) => { variant: "destructive", }); } finally { - setLoading(false); + // Only clear loading if it was set + if (!silent) { + setLoading(false); + } + if (isInitialLoad) { + setIsInitialLoad(false); + } } }; - // Set up realtime subscriptions - useRealtimeSubmissions({ - onInsert: (payload) => { - console.log('New submission received'); - toast({ - title: 'New Submission', - description: 'A new content submission has been added', - }); - fetchItems(activeEntityFilter, activeStatusFilter); - }, - onUpdate: (payload) => { - console.log('Submission updated'); - // Update items state directly for better UX - setItems(prevItems => - prevItems.map(item => - item.id === payload.new.id && item.type === 'content_submission' - ? { ...item, status: payload.new.status, content: { ...item.content, ...payload.new } } - : item - ) - ); - }, - onDelete: (payload) => { - console.log('Submission deleted'); - setItems(prevItems => - prevItems.filter(item => !(item.id === payload.old.id && item.type === 'content_submission')) - ); - }, - enabled: !!user, - }); - + // Initial fetch on mount and filter changes useEffect(() => { if (user) { - fetchItems(activeEntityFilter, activeStatusFilter); + fetchItems(activeEntityFilter, activeStatusFilter, false); // Show loading } }, [activeEntityFilter, activeStatusFilter, user]); + // Polling for auto-refresh + useEffect(() => { + if (!user || refreshMode !== 'auto' || isInitialLoad) return; + + const interval = setInterval(() => { + fetchItems(activeEntityFilter, activeStatusFilter, true); // Silent refresh + }, pollInterval); + + return () => { + clearInterval(interval); + }; + }, [user, refreshMode, pollInterval, activeEntityFilter, activeStatusFilter, isInitialLoad]); + const handleResetToPending = async (item: ModerationItem) => { setActionLoading(item.id); try { @@ -467,7 +464,6 @@ export const ModerationQueue = forwardRef((props, ref) => { ) => { // Prevent multiple clicks on the same item if (actionLoading === item.id) { - console.log('Action already in progress for item:', item.id); return; } @@ -527,8 +523,7 @@ export const ModerationQueue = forwardRef((props, ref) => { manufacturer_id: modelManufacturerId, category: item.content.new_ride_model.category, ride_type: item.content.new_ride_model.ride_type, - description: item.content.new_ride_model.description, - technical_specs: item.content.new_ride_model.technical_specs + description: item.content.new_ride_model.description }) .select() .single(); @@ -584,8 +579,6 @@ export const ModerationQueue = forwardRef((props, ref) => { // Handle photo submissions - create photos records when approved if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') { - console.log('🖼️ [PHOTO APPROVAL] Starting photo submission approval'); - try { // Fetch photo submission from new relational tables const { data: photoSubmission, error: fetchError } = await supabase @@ -598,15 +591,13 @@ export const ModerationQueue = forwardRef((props, ref) => { .eq('submission_id', item.id) .single(); - console.log('🖼️ [PHOTO APPROVAL] Fetched photo submission:', photoSubmission); - if (fetchError || !photoSubmission) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to fetch photo submission:', fetchError); + console.error('Failed to fetch photo submission:', fetchError); throw new Error('Failed to fetch photo submission data'); } if (!photoSubmission.items || photoSubmission.items.length === 0) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: No photo items found'); + console.error('No photo items found in submission'); throw new Error('No photos found in submission'); } @@ -616,10 +607,7 @@ export const ModerationQueue = forwardRef((props, ref) => { .select('id') .eq('submission_id', item.id); - console.log('🖼️ [PHOTO APPROVAL] Existing photos check:', existingPhotos); - if (existingPhotos && existingPhotos.length > 0) { - console.log('🖼️ [PHOTO APPROVAL] Photos already exist for this submission, skipping creation'); // Just update submission status const { error: updateError } = await supabase @@ -649,19 +637,15 @@ export const ModerationQueue = forwardRef((props, ref) => { approved_at: new Date().toISOString(), })); - console.log('🖼️ [PHOTO APPROVAL] Creating photo records:', photoRecords); - const { data: createdPhotos, error: insertError } = await supabase .from('photos') .insert(photoRecords) .select(); if (insertError) { - console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to insert photos:', insertError); + console.error('Failed to insert photos:', insertError); throw insertError; } - - console.log('🖼️ [PHOTO APPROVAL] ✅ Successfully created photos:', createdPhotos); } // Update submission status @@ -676,12 +660,10 @@ export const ModerationQueue = forwardRef((props, ref) => { .eq('id', item.id); if (updateError) { - console.error('🖼️ [PHOTO APPROVAL] Error updating submission:', updateError); + console.error('Error updating submission:', updateError); throw updateError; } - console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Photos approved and published'); - toast({ title: "Photos Approved", description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, @@ -692,8 +674,7 @@ export const ModerationQueue = forwardRef((props, ref) => { return; } catch (error: any) { - console.error('🖼️ [PHOTO APPROVAL] ❌ FATAL ERROR:', error); - console.error('🖼️ [PHOTO APPROVAL] Error details:', error.message, error.code, error.details); + console.error('Photo approval error:', error); throw error; } } @@ -707,8 +688,6 @@ export const ModerationQueue = forwardRef((props, ref) => { .in('status', ['pending', 'rejected']); if (!itemsError && submissionItems && submissionItems.length > 0) { - console.log(`Found ${submissionItems.length} pending submission items for ${item.id}`); - if (action === 'approved') { // Call the edge function to process all items const { data: approvalData, error: approvalError } = await supabase.functions.invoke( @@ -726,8 +705,6 @@ export const ModerationQueue = forwardRef((props, ref) => { throw new Error(`Failed to process submission items: ${approvalError.message}`); } - console.log('Submission items processed successfully:', approvalData); - toast({ title: "Submission Approved", description: `Successfully processed ${submissionItems.length} item(s)`, @@ -738,7 +715,6 @@ export const ModerationQueue = forwardRef((props, ref) => { return; } else if (action === 'rejected') { // Cascade rejection to all pending items - console.log('Cascading rejection to submission items'); const { error: rejectError } = await supabase .from('submission_items') .update({ @@ -752,8 +728,6 @@ export const ModerationQueue = forwardRef((props, ref) => { if (rejectError) { console.error('Failed to cascade rejection:', rejectError); // Don't fail the whole operation, just log it - } else { - console.log('Successfully cascaded rejection to submission items'); } } } @@ -781,8 +755,6 @@ export const ModerationQueue = forwardRef((props, ref) => { updateData.reviewer_notes = moderatorNotes; } - console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table); - const { error, data } = await supabase .from(table) .update(updateData) @@ -794,16 +766,12 @@ export const ModerationQueue = forwardRef((props, ref) => { throw error; } - console.log('Update response:', { data, rowsAffected: data?.length }); - // Check if the update actually affected any rows if (!data || data.length === 0) { console.error('No rows were updated. This might be due to RLS policies or the item not existing.'); throw new Error('Failed to update item - no rows affected. You might not have permission to moderate this content.'); } - console.log('Update successful, rows affected:', data.length); - toast({ title: `Content ${action}`, description: `The ${item.type} has been ${action}`, @@ -823,10 +791,10 @@ export const ModerationQueue = forwardRef((props, ref) => { return newNotes; }); - // Only refresh if we're viewing a filter that should no longer show this item + // Refresh if needed based on filter if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) || (activeStatusFilter === 'flagged' && (action === 'approved' || action === 'rejected'))) { - console.log('Item no longer matches filter, removing from view'); + // Item no longer matches filter } } catch (error: any) { @@ -854,7 +822,6 @@ export const ModerationQueue = forwardRef((props, ref) => { // Prevent duplicate calls if (actionLoading === item.id) { - console.log('Deletion already in progress for:', item.id); return; } @@ -864,8 +831,6 @@ export const ModerationQueue = forwardRef((props, ref) => { setItems(prev => prev.filter(i => i.id !== item.id)); try { - console.log('Starting deletion process for submission:', item.id); - // Step 1: Extract photo IDs from the submission content const photoIds: string[] = []; const validImageIds: string[] = []; @@ -875,18 +840,12 @@ export const ModerationQueue = forwardRef((props, ref) => { const photosArray = item.content?.content?.photos || item.content?.photos; if (photosArray && Array.isArray(photosArray)) { - console.log('Processing photos from content:', photosArray); for (const photo of photosArray) { - console.log('Processing photo object:', photo); - console.log('Photo keys:', Object.keys(photo)); - console.log('photo.imageId:', photo.imageId, 'type:', typeof photo.imageId); - let imageId = ''; // First try to use the stored imageId directly if (photo.imageId) { imageId = photo.imageId; - console.log('Using stored image ID:', imageId); } else if (photo.url) { // Check if this looks like a Cloudflare image ID (not a blob URL) if (photo.url.startsWith('blob:')) { @@ -908,7 +867,6 @@ export const ModerationQueue = forwardRef((props, ref) => { imageId = cloudflareMatch[1]; } } - console.log('Extracted image ID from URL:', imageId, 'from URL:', photo.url); } if (imageId) { @@ -921,14 +879,10 @@ export const ModerationQueue = forwardRef((props, ref) => { } } - console.log(`Found ${validImageIds.length} valid image IDs to delete, ${skippedPhotos.length} photos will be orphaned`); - // Step 2: Delete photos from Cloudflare Images (if any valid IDs) if (validImageIds.length > 0) { const deletePromises = validImageIds.map(async (imageId) => { try { - console.log('Attempting to delete image from Cloudflare:', imageId); - // Use Supabase SDK - automatically includes session token const { data, error } = await supabase.functions.invoke('upload-image', { method: 'DELETE', @@ -939,8 +893,6 @@ export const ModerationQueue = forwardRef((props, ref) => { throw new Error(`Failed to delete image: ${error.message}`); } - console.log('Successfully deleted image:', imageId, data); - } catch (deleteError) { console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError); // Continue with other deletions - don't fail the entire operation @@ -952,7 +904,6 @@ export const ModerationQueue = forwardRef((props, ref) => { } // Step 3: Delete the submission from the database - console.log('Deleting submission from database:', item.id); const { error } = await supabase .from('content_submissions') .delete() @@ -973,8 +924,6 @@ export const ModerationQueue = forwardRef((props, ref) => { if (checkData && !checkError) { console.error('DELETION FAILED: Item still exists in database after delete operation'); throw new Error('Deletion failed - item still exists in database'); - } else { - console.log('Verified: Submission successfully deleted from database'); } const deletedCount = validImageIds.length; @@ -1201,7 +1150,6 @@ export const ModerationQueue = forwardRef((props, ref) => { console.error('Failed to load review photo:', photo.url); (e.target as HTMLImageElement).style.display = 'none'; }} - onLoad={() => console.log('Review photo loaded:', photo.url)} />
@@ -1254,21 +1202,29 @@ export const ModerationQueue = forwardRef((props, ref) => { src={photo.url} alt={`Photo ${index + 1}: ${photo.filename}`} className="w-full max-h-64 object-contain rounded hover:opacity-80 transition-opacity" - onError={(e) => { - console.error('Failed to load photo submission:', photo); - const target = e.target as HTMLImageElement; - target.style.display = 'none'; - const parent = target.parentElement; - if (parent) { - parent.innerHTML = ` -
-
⚠️ Image failed to load
-
${photo.url}
-
- `; - } - }} - onLoad={() => console.log('Photo submission loaded:', photo.url)} + onError={(e) => { + console.error('Failed to load photo submission:', photo); + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + const parent = target.parentElement; + if (parent) { + // Create elements safely using DOM API to prevent XSS + const errorContainer = document.createElement('div'); + errorContainer.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs'; + + const errorIcon = document.createElement('div'); + errorIcon.textContent = '⚠️ Image failed to load'; + + const urlDisplay = document.createElement('div'); + urlDisplay.className = 'mt-1 font-mono text-xs break-all px-2'; + // Use textContent to prevent XSS - it escapes HTML automatically + urlDisplay.textContent = photo.url; + + errorContainer.appendChild(errorIcon); + errorContainer.appendChild(urlDisplay); + parent.appendChild(errorContainer); + } + }} />
@@ -1468,13 +1424,17 @@ export const ModerationQueue = forwardRef((props, ref) => { {item.content.ride?.max_speed_kmh && (
Max Speed: - {item.content.ride.max_speed_kmh} km/h + + +
)} {item.content.ride?.max_height_meters && (
Max Height: - {item.content.ride.max_height_meters} m + + +
)}
@@ -1487,10 +1447,10 @@ export const ModerationQueue = forwardRef((props, ref) => { item.submission_type === 'property_owner' || item.submission_type === 'park' || item.submission_type === 'ride') ? ( - ) : (
@@ -1736,6 +1696,9 @@ export const ModerationQueue = forwardRef((props, ref) => {
{/* Filter Bar */}
+
+

Moderation Queue

+
diff --git a/src/components/moderation/PhotoComparison.tsx b/src/components/moderation/PhotoComparison.tsx new file mode 100644 index 00000000..746c8acc --- /dev/null +++ b/src/components/moderation/PhotoComparison.tsx @@ -0,0 +1,159 @@ +import { Badge } from '@/components/ui/badge'; +import { ImageIcon, Trash2, Edit } from 'lucide-react'; + +interface PhotoAdditionPreviewProps { + photos: Array<{ + url: string; + title?: string; + caption?: string; + }>; + compact?: boolean; +} + +export function PhotoAdditionPreview({ photos, compact = false }: PhotoAdditionPreviewProps) { + if (compact) { + return ( + + + +{photos.length} Photo{photos.length > 1 ? 's' : ''} + + ); + } + + return ( +
+
+ + Adding {photos.length} Photo{photos.length > 1 ? 's' : ''} +
+ +
+ {photos.slice(0, 6).map((photo, idx) => ( +
+ {photo.title + {(photo.title || photo.caption) && ( +
+ {photo.title || photo.caption} +
+ )} +
+ ))} + {photos.length > 6 && ( +
+ + +{photos.length - 6} more + +
+ )} +
+
+ ); +} + +interface PhotoEditPreviewProps { + photo: { + url: string; + oldCaption?: string; + newCaption?: string; + oldTitle?: string; + newTitle?: string; + }; + compact?: boolean; +} + +export function PhotoEditPreview({ photo, compact = false }: PhotoEditPreviewProps) { + if (compact) { + return ( + + + Photo Edit + + ); + } + + return ( +
+
+ + Photo Metadata Edit +
+ +
+ Photo being edited + +
+ {photo.oldTitle !== photo.newTitle && ( +
+
Title:
+
{photo.oldTitle || 'None'}
+
{photo.newTitle || 'None'}
+
+ )} + + {photo.oldCaption !== photo.newCaption && ( +
+
Caption:
+
{photo.oldCaption || 'None'}
+
{photo.newCaption || 'None'}
+
+ )} +
+
+
+ ); +} + +interface PhotoDeletionPreviewProps { + photo: { + url: string; + title?: string; + caption?: string; + }; + compact?: boolean; +} + +export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) { + if (compact) { + return ( + + + Delete Photo + + ); + } + + return ( +
+
+ + Deleting Photo +
+ +
+ {photo.title + + {(photo.title || photo.caption) && ( +
+ {photo.title &&
{photo.title}
} + {photo.caption &&
{photo.caption}
} +
+ )} +
+
+ ); +} diff --git a/src/components/moderation/ReportsQueue.tsx b/src/components/moderation/ReportsQueue.tsx index a42ba1be..b07642f4 100644 --- a/src/components/moderation/ReportsQueue.tsx +++ b/src/components/moderation/ReportsQueue.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -8,6 +8,8 @@ import { Label } from '@/components/ui/label'; import { supabase } from '@/integrations/supabase/client'; import { useToast } from '@/hooks/use-toast'; import { format } from 'date-fns'; +import { useAdminSettings } from '@/hooks/useAdminSettings'; +import { useAuth } from '@/hooks/useAuth'; interface Report { id: string; @@ -38,14 +40,35 @@ const STATUS_COLORS = { dismissed: 'outline', } as const; -export function ReportsQueue() { +export interface ReportsQueueRef { + refresh: () => void; +} + +export const ReportsQueue = forwardRef((props, ref) => { const [reports, setReports] = useState([]); const [loading, setLoading] = useState(true); + const [isInitialLoad, setIsInitialLoad] = useState(true); const [actionLoading, setActionLoading] = useState(null); const { toast } = useToast(); + const { user } = useAuth(); - const fetchReports = async () => { + // Get admin settings for polling configuration + const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings(); + const refreshMode = getAdminPanelRefreshMode(); + const pollInterval = getAdminPanelPollInterval(); + + // Expose refresh method via ref + useImperativeHandle(ref, () => ({ + refresh: () => fetchReports(false) // Manual refresh shows loading + }), []); + + const fetchReports = async (silent = false) => { try { + // Only show loading on initial load + if (!silent) { + setLoading(true); + } + const { data, error } = await supabase .from('reports') .select(` @@ -106,13 +129,35 @@ export function ReportsQueue() { variant: "destructive", }); } finally { - setLoading(false); + // Only clear loading if it was set + if (!silent) { + setLoading(false); + } + if (isInitialLoad) { + setIsInitialLoad(false); + } } }; + // Initial fetch on mount useEffect(() => { - fetchReports(); - }, []); + if (user) { + fetchReports(false); // Show loading + } + }, [user]); + + // Polling for auto-refresh + useEffect(() => { + if (!user || refreshMode !== 'auto' || isInitialLoad) return; + + const interval = setInterval(() => { + fetchReports(true); // Silent refresh + }, pollInterval); + + return () => { + clearInterval(interval); + }; + }, [user, refreshMode, pollInterval, isInitialLoad]); const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => { setActionLoading(reportId); @@ -258,4 +303,4 @@ export function ReportsQueue() { ))}
); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/src/components/moderation/SpecialFieldDisplay.tsx b/src/components/moderation/SpecialFieldDisplay.tsx new file mode 100644 index 00000000..296d0f24 --- /dev/null +++ b/src/components/moderation/SpecialFieldDisplay.tsx @@ -0,0 +1,327 @@ +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { MeasurementDisplay } from '@/components/ui/measurement-display'; +import { SpeedDisplay } from '@/components/ui/speed-display'; +import { MapPin, ArrowRight, Calendar, ExternalLink } from 'lucide-react'; +import type { FieldChange } from '@/lib/submissionChangeDetection'; +import { formatFieldValue } from '@/lib/submissionChangeDetection'; + +interface SpecialFieldDisplayProps { + change: FieldChange; + compact?: boolean; +} + +export function SpecialFieldDisplay({ change, compact = false }: SpecialFieldDisplayProps) { + const fieldName = change.field.toLowerCase(); + + // Detect field type + if (fieldName.includes('speed') || fieldName === 'max_speed_kmh') { + return ; + } + + if (fieldName.includes('height') || fieldName.includes('length') || + fieldName === 'max_height_meters' || fieldName === 'length_meters' || + fieldName === 'drop_height_meters') { + return ; + } + + if (fieldName === 'status') { + return ; + } + + if (fieldName.includes('date') && !fieldName.includes('updated') && !fieldName.includes('created')) { + return ; + } + + if (fieldName.includes('_id') && fieldName !== 'id' && fieldName !== 'user_id') { + return ; + } + + if (fieldName === 'latitude' || fieldName === 'longitude') { + return ; + } + + // Fallback to null, will be handled by regular FieldDiff + return null; +} + +function SpeedFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + if (compact) { + return ( + + Speed + + ); + } + + const formatFieldName = (name: string) => + name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() + .replace(/^./, str => str.toUpperCase()); + + return ( +
+
{formatFieldName(change.field)}
+ + {change.changeType === 'modified' && ( +
+
+ +
+ +
+ +
+
+ )} + + {change.changeType === 'added' && ( +
+ + +
+ )} + + {change.changeType === 'removed' && ( +
+ +
+ )} +
+ ); +} + +function MeasurementFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + if (compact) { + return ( + + Measurement + + ); + } + + const formatFieldName = (name: string) => + name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() + .replace(/^./, str => str.toUpperCase()); + + return ( +
+
{formatFieldName(change.field)}
+ + {change.changeType === 'modified' && ( +
+
+ +
+ +
+ +
+
+ )} + + {change.changeType === 'added' && ( +
+ + +
+ )} + + {change.changeType === 'removed' && ( +
+ +
+ )} +
+ ); +} + +function StatusFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + const getStatusColor = (status: string) => { + const statusLower = String(status).toLowerCase(); + if (statusLower === 'operating' || statusLower === 'active') return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20'; + if (statusLower === 'closed' || statusLower === 'inactive') return 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20'; + if (statusLower === 'under_construction' || statusLower === 'pending') return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'; + return 'bg-muted/30 text-muted-foreground'; + }; + + if (compact) { + return ( + + Status + + ); + } + + const formatFieldName = (name: string) => + name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() + .replace(/^./, str => str.toUpperCase()); + + return ( +
+
{formatFieldName(change.field)}
+ + {change.changeType === 'modified' && ( +
+ + {formatFieldValue(change.oldValue)} + + + + {formatFieldValue(change.newValue)} + +
+ )} + + {change.changeType === 'added' && ( + + {formatFieldValue(change.newValue)} + + )} + + {change.changeType === 'removed' && ( + + {formatFieldValue(change.oldValue)} + + )} +
+ ); +} + +function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + if (compact) { + return ( + + + Date + + ); + } + + const formatFieldName = (name: string) => + name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() + .replace(/^./, str => str.toUpperCase()); + + return ( +
+
+ + {formatFieldName(change.field)} +
+ + {change.changeType === 'modified' && ( +
+ + {formatFieldValue(change.oldValue)} + + + + {formatFieldValue(change.newValue)} + +
+ )} + + {change.changeType === 'added' && ( +
+ + {formatFieldValue(change.newValue)} +
+ )} + + {change.changeType === 'removed' && ( +
+ {formatFieldValue(change.oldValue)} +
+ )} +
+ ); +} + +function RelationshipFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + // This would ideally fetch entity names, but for now we show IDs with better formatting + const formatFieldName = (name: string) => + name.replace(/_id$/, '').replace(/_/g, ' ').trim() + .replace(/^./, str => str.toUpperCase()); + + if (compact) { + return ( + + {formatFieldName(change.field)} + + ); + } + + return ( +
+
{formatFieldName(change.field)}
+ + {change.changeType === 'modified' && ( +
+ + {String(change.oldValue).slice(0, 8)}... + + + + {String(change.newValue).slice(0, 8)}... + +
+ )} + + {change.changeType === 'added' && ( +
+ + {String(change.newValue).slice(0, 8)}... +
+ )} + + {change.changeType === 'removed' && ( +
+ {String(change.oldValue).slice(0, 8)}... +
+ )} +
+ ); +} + +function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) { + if (compact) { + return ( + + + Coordinates + + ); + } + + const formatFieldName = (name: string) => + name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim() + .replace(/^./, str => str.toUpperCase()); + + return ( +
+
+ + {formatFieldName(change.field)} +
+ + {change.changeType === 'modified' && ( +
+ + {Number(change.oldValue).toFixed(6)}° + + + + {Number(change.newValue).toFixed(6)}° + +
+ )} + + {change.changeType === 'added' && ( +
+ + {Number(change.newValue).toFixed(6)}° +
+ )} + + {change.changeType === 'removed' && ( +
+ {Number(change.oldValue).toFixed(6)}° +
+ )} +
+ ); +} diff --git a/src/components/moderation/SubmissionChangesDisplay.tsx b/src/components/moderation/SubmissionChangesDisplay.tsx new file mode 100644 index 00000000..31de9e96 --- /dev/null +++ b/src/components/moderation/SubmissionChangesDisplay.tsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison'; +import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './PhotoComparison'; +import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection'; +import type { SubmissionItemData } from '@/types/submissions'; +import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService'; +import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react'; + +interface SubmissionChangesDisplayProps { + item: SubmissionItemData | SubmissionItemWithDeps; + view?: 'summary' | 'detailed'; + showImages?: boolean; + submissionId?: string; +} + +// Helper to determine change magnitude +function getChangeMagnitude(totalChanges: number, hasImages: boolean, action: string) { + if (action === 'delete') return { label: 'Deletion', variant: 'destructive' as const, icon: AlertTriangle }; + if (action === 'create') return { label: 'New', variant: 'default' as const, icon: Plus }; + if (hasImages) return { label: 'Major', variant: 'default' as const, icon: Edit }; + if (totalChanges >= 5) return { label: 'Major', variant: 'default' as const, icon: Edit }; + if (totalChanges >= 3) return { label: 'Moderate', variant: 'secondary' as const, icon: Edit }; + return { label: 'Minor', variant: 'outline' as const, icon: Edit }; +} + +export function SubmissionChangesDisplay({ + item, + view = 'summary', + showImages = true, + submissionId +}: SubmissionChangesDisplayProps) { + const [changes, setChanges] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const loadChanges = async () => { + setLoading(true); + const detectedChanges = await detectChanges(item, submissionId); + setChanges(detectedChanges); + setLoading(false); + }; + loadChanges(); + }, [item, submissionId]); + + if (loading || !changes) { + return ; + } + + // Get appropriate icon for entity type + const getEntityIcon = () => { + const iconClass = "h-4 w-4"; + switch (item.item_type) { + case 'park': return ; + case 'ride': return ; + case 'manufacturer': + case 'operator': + case 'property_owner': + case 'designer': return ; + case 'photo': return ; + default: return ; + } + }; + + // Get action badge + const getActionBadge = () => { + switch (changes.action) { + case 'create': + return New; + case 'edit': + return Edit; + case 'delete': + return Delete; + } + }; + + const magnitude = getChangeMagnitude( + changes.totalChanges, + changes.imageChanges.length > 0, + changes.action + ); + + if (view === 'summary') { + return ( +
+
+ {getEntityIcon()} + {changes.entityName} + {getActionBadge()} + {changes.action === 'edit' && ( + + {magnitude.label} Change + + )} +
+ + {changes.action === 'edit' && changes.totalChanges > 0 && ( +
+ {changes.fieldChanges.slice(0, 5).map((change, idx) => ( + + ))} + {changes.imageChanges.map((change, idx) => ( + + ))} + {changes.photoChanges.map((change, idx) => { + if (change.type === 'added' && change.photos) { + return ; + } + if (change.type === 'edited' && change.photo) { + return ; + } + if (change.type === 'deleted' && change.photo) { + return ; + } + return null; + })} + {changes.hasLocationChange && ( + + Location + + )} + {changes.totalChanges > 5 && ( + + +{changes.totalChanges - 5} more + + )} +
+ )} + + {changes.action === 'create' && item.item_data?.description && ( +
+ {item.item_data.description} +
+ )} + + {changes.action === 'delete' && ( +
+ Marked for deletion +
+ )} +
+ ); + } + + // Detailed view + return ( +
+
+ {getEntityIcon()} +

{changes.entityName}

+ {getActionBadge()} +
+ + {changes.action === 'create' && ( +
+ Creating new {item.item_type} +
+ )} + + {changes.action === 'delete' && ( +
+ This {item.item_type} will be deleted +
+ )} + + {changes.action === 'edit' && changes.totalChanges > 0 && ( + <> + {changes.fieldChanges.length > 0 && ( +
+

Field Changes ({changes.fieldChanges.length})

+
+ {changes.fieldChanges.map((change, idx) => ( + + ))} +
+
+ )} + + {showImages && changes.imageChanges.length > 0 && ( +
+

Image Changes

+
+ {changes.imageChanges.map((change, idx) => ( + + ))} +
+
+ )} + + {showImages && changes.photoChanges.length > 0 && ( +
+

Photo Changes

+
+ {changes.photoChanges.map((change, idx) => { + if (change.type === 'added' && change.photos) { + return ; + } + if (change.type === 'edited' && change.photo) { + return ; + } + if (change.type === 'deleted' && change.photo) { + return ; + } + return null; + })} +
+
+ )} + + {changes.hasLocationChange && ( +
+

Location Change

+ +
+ )} + + )} + + {changes.action === 'edit' && changes.totalChanges === 0 && ( +
+ No changes detected +
+ )} +
+ ); +} diff --git a/src/components/moderation/SubmissionItemsList.tsx b/src/components/moderation/SubmissionItemsList.tsx new file mode 100644 index 00000000..a9e52a7e --- /dev/null +++ b/src/components/moderation/SubmissionItemsList.tsx @@ -0,0 +1,112 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { SubmissionChangesDisplay } from './SubmissionChangesDisplay'; +import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertCircle } from 'lucide-react'; +import type { SubmissionItemData } from '@/types/submissions'; + +interface SubmissionItemsListProps { + submissionId: string; + view?: 'summary' | 'detailed'; + showImages?: boolean; +} + +export function SubmissionItemsList({ + submissionId, + view = 'summary', + showImages = true +}: SubmissionItemsListProps) { + const [items, setItems] = useState([]); + const [hasPhotos, setHasPhotos] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchSubmissionItems(); + }, [submissionId]); + + const fetchSubmissionItems = async () => { + try { + setLoading(true); + setError(null); + + // Fetch submission items + const { data: itemsData, error: itemsError } = await supabase + .from('submission_items') + .select('*') + .eq('submission_id', submissionId) + .order('order_index'); + + if (itemsError) throw itemsError; + + // Check for photo submissions (using array query to avoid 406) + const { data: photoData, error: photoError } = await supabase + .from('photo_submissions') + .select('id') + .eq('submission_id', submissionId); + + if (photoError) { + console.warn('Error checking photo submissions:', photoError); + } + + setItems((itemsData || []) as SubmissionItemData[]); + setHasPhotos(photoData && photoData.length > 0); + } catch (err) { + console.error('Error fetching submission items:', err); + setError('Failed to load submission details'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+ + {view === 'detailed' && } +
+ ); + } + + if (error) { + return ( + + + {error} + + ); + } + + if (items.length === 0 && !hasPhotos) { + return ( +
+ No items found for this submission +
+ ); + } + + return ( +
+ {/* Show regular submission items */} + {items.map((item) => ( +
+ +
+ ))} + + {/* Show photo submission if exists */} + {hasPhotos && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/moderation/SubmissionReviewManager.tsx b/src/components/moderation/SubmissionReviewManager.tsx index 6529dd03..e7500253 100644 --- a/src/components/moderation/SubmissionReviewManager.tsx +++ b/src/components/moderation/SubmissionReviewManager.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'; import { useToast } from '@/hooks/use-toast'; import { useUserRole } from '@/hooks/useUserRole'; import { useAuth } from '@/hooks/useAuth'; -import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems'; import { fetchSubmissionItems, buildDependencyTree, @@ -60,20 +59,6 @@ export function SubmissionReviewManager({ const isMobile = useIsMobile(); const Container = isMobile ? Sheet : Dialog; - // Set up realtime subscription for submission items - useRealtimeSubmissionItems({ - submissionId, - onUpdate: (payload) => { - console.log('Submission item updated in real-time:', payload); - toast({ - title: 'Item Updated', - description: 'A submission item was updated by another moderator', - }); - loadSubmissionItems(); - }, - enabled: open && !!submissionId, - }); - useEffect(() => { if (open && submissionId) { loadSubmissionItems(); @@ -473,7 +458,11 @@ export function SubmissionReviewManager({ handleEdit(item)} - onStatusChange={(status) => handleItemStatusChange(item.id, status)} + onStatusChange={async () => { + // Status changes handled via approve/reject actions + await loadSubmissionItems(); + }} + submissionId={submissionId} />
))} diff --git a/src/components/operators/OperatorCard.tsx b/src/components/operators/OperatorCard.tsx index 5a96d15a..6135aa92 100644 --- a/src/components/operators/OperatorCard.tsx +++ b/src/components/operators/OperatorCard.tsx @@ -13,7 +13,7 @@ const OperatorCard = ({ company }: OperatorCardProps) => { const navigate = useNavigate(); const handleClick = () => { - navigate(`/operators/${company.slug}/parks/`); + navigate(`/operators/${company.slug}`); }; const getCompanyIcon = () => { diff --git a/src/components/park-owners/ParkOwnerCard.tsx b/src/components/park-owners/ParkOwnerCard.tsx index 0b612438..a0cc1c10 100644 --- a/src/components/park-owners/ParkOwnerCard.tsx +++ b/src/components/park-owners/ParkOwnerCard.tsx @@ -13,7 +13,7 @@ const ParkOwnerCard = ({ company }: ParkOwnerCardProps) => { const navigate = useNavigate(); const handleClick = () => { - navigate(`/owners/${company.slug}/parks/`); + navigate(`/owners/${company.slug}`); }; const getCompanyIcon = () => { diff --git a/src/components/parks/ParkCard.tsx b/src/components/parks/ParkCard.tsx index b8890bc8..6427925d 100644 --- a/src/components/parks/ParkCard.tsx +++ b/src/components/parks/ParkCard.tsx @@ -59,7 +59,7 @@ export function ParkCard({ park }: ParkCardProps) { )} {/* Gradient Overlay */} -
+
{/* Status Badge */} diff --git a/src/components/reviews/ReviewForm.tsx b/src/components/reviews/ReviewForm.tsx index 70107e84..c89592e5 100644 --- a/src/components/reviews/ReviewForm.tsx +++ b/src/components/reviews/ReviewForm.tsx @@ -19,7 +19,14 @@ const reviewSchema = z.object({ title: z.string().optional(), content: z.string().min(10, 'Review must be at least 10 characters long'), visit_date: z.string().optional(), - wait_time_minutes: z.number().optional(), + wait_time_minutes: z.preprocess( + (val) => { + if (val === '' || val === null || val === undefined) return undefined; + const num = Number(val); + return isNaN(num) ? undefined : num; + }, + z.number().positive().optional() + ), photos: z.array(z.string()).optional() }); type ReviewFormData = z.infer; @@ -189,7 +196,8 @@ export function ReviewForm({ {entityType === 'ride' &&
v === '' || isNaN(v) ? undefined : v })} />
} diff --git a/src/components/reviews/ReviewsList.tsx b/src/components/reviews/ReviewsList.tsx index 6d2441e3..38a3eca7 100644 --- a/src/components/reviews/ReviewsList.tsx +++ b/src/components/reviews/ReviewsList.tsx @@ -49,7 +49,7 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro .from('reviews') .select(` *, - profiles:user_id(username, avatar_url, display_name) + profiles!reviews_user_id_fkey(username, avatar_url, display_name) `) .eq('moderation_status', 'approved') .order('created_at', { ascending: false }); diff --git a/src/components/rides/CoasterStatistics.tsx b/src/components/rides/CoasterStatistics.tsx index db0a2073..f50dbd77 100644 --- a/src/components/rides/CoasterStatistics.tsx +++ b/src/components/rides/CoasterStatistics.tsx @@ -1,16 +1,44 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { RideCoasterStat } from "@/types/database"; import { TrendingUp } from "lucide-react"; +import { useUnitPreferences } from "@/hooks/useUnitPreferences"; +import { convertValueFromMetric, detectUnitType, getMetricUnit, getDisplayUnit } from "@/lib/units"; interface CoasterStatisticsProps { statistics?: RideCoasterStat[]; } export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => { + const { preferences } = useUnitPreferences(); + if (!statistics || statistics.length === 0) { return null; } + const getDisplayValue = (stat: RideCoasterStat) => { + if (!stat.unit) { + return stat.stat_value.toLocaleString(); + } + + const unitType = detectUnitType(stat.unit); + if (unitType === 'unknown') { + return `${stat.stat_value.toLocaleString()} ${stat.unit}`; + } + + // stat.unit is the metric unit stored in DB (e.g., "km/h") + // Get the target display unit based on user preference (e.g., "mph" for imperial) + const displayUnit = getDisplayUnit(stat.unit, preferences.measurement_system); + + // Convert from metric (stat.unit) to display unit + const convertedValue = convertValueFromMetric( + stat.stat_value, + displayUnit, // Target unit (mph, ft, in) + stat.unit // Source metric unit (km/h, m, cm) + ); + + return `${convertedValue.toLocaleString()} ${displayUnit}`; + }; + // Group stats by category const groupedStats = statistics.reduce((acc, stat) => { const category = stat.category || 'General'; @@ -53,8 +81,7 @@ export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => { )}
- {stat.stat_value.toLocaleString()} - {stat.unit && ` ${stat.unit}`} + {getDisplayValue(stat)}
))} diff --git a/src/components/rides/RideCard.tsx b/src/components/rides/RideCard.tsx index 431aca00..ab01de83 100644 --- a/src/components/rides/RideCard.tsx +++ b/src/components/rides/RideCard.tsx @@ -34,10 +34,10 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid const getStatusColor = (status: string) => { switch (status) { - case 'operating': return 'bg-green-500/20 text-green-400 border-green-500/30'; - case 'seasonal': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'; - case 'under_construction': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; - default: return 'bg-red-500/20 text-red-400 border-red-500/30'; + case 'operating': return 'bg-green-500/50 text-green-200 border-green-500/50'; + case 'seasonal': return 'bg-yellow-500/50 text-yellow-200 border-yellow-500/50'; + case 'under_construction': return 'bg-blue-500/50 text-blue-200 border-blue-500/50'; + default: return 'bg-red-500/50 text-red-200 border-red-500/50'; } }; diff --git a/src/components/rides/RideHighlights.tsx b/src/components/rides/RideHighlights.tsx index b7a24332..a1d1a3ac 100644 --- a/src/components/rides/RideHighlights.tsx +++ b/src/components/rides/RideHighlights.tsx @@ -1,11 +1,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Zap, TrendingUp, Award, Sparkles } from 'lucide-react'; +import { MeasurementDisplay } from '@/components/ui/measurement-display'; interface RideHighlight { icon: React.ReactNode; label: string; - value: string; + value: React.ReactNode; } interface RideHighlightsProps { @@ -20,7 +21,7 @@ export function RideHighlights({ ride }: RideHighlightsProps) { highlights.push({ icon: , label: 'High Speed', - value: `${ride.max_speed_kmh} km/h` + value: }); } @@ -29,7 +30,7 @@ export function RideHighlights({ ride }: RideHighlightsProps) { highlights.push({ icon: , label: 'Tall Structure', - value: `${ride.max_height_meters}m high` + value: }); } diff --git a/src/components/rides/RideModelCard.tsx b/src/components/rides/RideModelCard.tsx new file mode 100644 index 00000000..2bca5c26 --- /dev/null +++ b/src/components/rides/RideModelCard.tsx @@ -0,0 +1,84 @@ +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { FerrisWheel } from 'lucide-react'; +import { RideModel } from '@/types/database'; +import { useNavigate } from 'react-router-dom'; + +interface RideModelCardProps { + model: RideModel; + manufacturerSlug: string; +} + +export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) { + const navigate = useNavigate(); + + const formatCategory = (category: string) => { + return category.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + }; + + const formatRideType = (type: string) => { + return type.split('_').map(word => + word.charAt(0).toUpperCase() + word.slice(1) + ).join(' '); + }; + + const rideCount = (model as any).rides?.[0]?.count || 0; + + return ( + +
+ {((model as any).card_image_url || (model as any).card_image_id) ? ( + {model.name} + ) : ( +
+ +
+ )} +
+ + +
+

+ {model.name} +

+ {model.description && ( +

+ {model.description} +

+ )} +
+ +
+ + {formatCategory(model.category)} + + + {formatRideType(model.ride_type)} + +
+ +
+ + {rideCount} {rideCount === 1 ? 'ride' : 'rides'} + + +
+
+
+ ); +} diff --git a/src/components/rides/RidePhotoGallery.tsx b/src/components/rides/RidePhotoGallery.tsx deleted file mode 100644 index 97017038..00000000 --- a/src/components/rides/RidePhotoGallery.tsx +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @deprecated Use EntityPhotoGallery directly or import from RidePhotoGalleryWrapper - * This file is kept for backwards compatibility - */ -export { RidePhotoGallery } from './RidePhotoGalleryWrapper'; \ No newline at end of file diff --git a/src/components/rides/RidePhotoGalleryWrapper.tsx b/src/components/rides/RidePhotoGalleryWrapper.tsx deleted file mode 100644 index 1645061a..00000000 --- a/src/components/rides/RidePhotoGalleryWrapper.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery'; - -interface RidePhotoGalleryProps { - rideId: string; - rideName: string; - parkId?: string; -} - -/** - * Backwards-compatible wrapper for RidePhotoGallery - * Uses the generic EntityPhotoGallery component internally - */ -export function RidePhotoGallery({ rideId, rideName, parkId }: RidePhotoGalleryProps) { - return ( - - ); -} diff --git a/src/components/rides/TechnicalSpecifications.tsx b/src/components/rides/TechnicalSpecifications.tsx index 5322064a..860806bb 100644 --- a/src/components/rides/TechnicalSpecifications.tsx +++ b/src/components/rides/TechnicalSpecifications.tsx @@ -1,16 +1,51 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { RideTechnicalSpec } from "@/types/database"; import { Wrench } from "lucide-react"; +import { useUnitPreferences } from "@/hooks/useUnitPreferences"; +import { convertValueFromMetric, detectUnitType, getMetricUnit, getDisplayUnit } from "@/lib/units"; interface TechnicalSpecificationsProps { specifications?: RideTechnicalSpec[]; } export const TechnicalSpecifications = ({ specifications }: TechnicalSpecificationsProps) => { + const { preferences } = useUnitPreferences(); + if (!specifications || specifications.length === 0) { return null; } + const getDisplayValue = (spec: RideTechnicalSpec) => { + // If no unit, return as-is + if (!spec.unit) { + return spec.spec_value; + } + + const unitType = detectUnitType(spec.unit); + if (unitType === 'unknown') { + return `${spec.spec_value} ${spec.unit}`; + } + + const numericValue = parseFloat(spec.spec_value); + + if (isNaN(numericValue)) { + return spec.spec_value; + } + + // spec.unit is the metric unit stored in DB (e.g., "km/h") + // Get the target display unit based on user preference (e.g., "mph" for imperial) + const displayUnit = getDisplayUnit(spec.unit, preferences.measurement_system); + + // Convert from metric (spec.unit) to display unit + const convertedValue = convertValueFromMetric( + numericValue, + displayUnit, // Target unit (mph, ft, in) + spec.unit // Source metric unit (km/h, m, cm) + ); + + return `${convertedValue.toLocaleString()} ${displayUnit}`; + }; + // Group specs by category const groupedSpecs = specifications.reduce((acc, spec) => { const category = spec.category || 'General'; @@ -46,8 +81,7 @@ export const TechnicalSpecifications = ({ specifications }: TechnicalSpecificati > {spec.spec_name} - {spec.spec_value} - {spec.unit && ` ${spec.unit}`} + {getDisplayValue(spec)}
))} diff --git a/src/components/search/AutocompleteSearch.tsx b/src/components/search/AutocompleteSearch.tsx index 89bde165..24160beb 100644 --- a/src/components/search/AutocompleteSearch.tsx +++ b/src/components/search/AutocompleteSearch.tsx @@ -123,10 +123,37 @@ export function AutocompleteSearch({ navigate(`/parks/${searchResult.slug || searchResult.id}`); break; case 'ride': - navigate(`/rides/${searchResult.id}`); + const parkSlug = (searchResult.data as any).park?.slug; + const rideSlug = searchResult.slug; + if (parkSlug && rideSlug) { + navigate(`/parks/${parkSlug}/rides/${rideSlug}`); + } else { + navigate(`/rides/${searchResult.id}`); + } break; case 'company': - navigate(`/companies/${searchResult.id}`); + const companyType = (searchResult.data as any).company_type; + const companySlug = searchResult.slug; + if (companyType && companySlug) { + switch (companyType) { + case 'operator': + navigate(`/operators/${companySlug}`); + break; + case 'property_owner': + navigate(`/owners/${companySlug}`); + break; + case 'manufacturer': + navigate(`/manufacturers/${companySlug}`); + break; + case 'designer': + navigate(`/designers/${companySlug}`); + break; + default: + navigate(`/companies/${searchResult.id}`); + } + } else { + navigate(`/companies/${searchResult.id}`); + } break; } } diff --git a/src/components/search/EnhancedSearchResults.tsx b/src/components/search/EnhancedSearchResults.tsx index ed214808..e1cc8bb6 100644 --- a/src/components/search/EnhancedSearchResults.tsx +++ b/src/components/search/EnhancedSearchResults.tsx @@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Star, MapPin, Zap, Factory, Clock, Users, Calendar, Ruler, Gauge, Building } from 'lucide-react'; import { SearchResult } from '@/hooks/useSearch'; +import { MeasurementDisplay } from '@/components/ui/measurement-display'; interface EnhancedSearchResultsProps { results: SearchResult[]; @@ -94,13 +95,13 @@ export function EnhancedSearchResults({ results, loading, hasMore, onLoadMore }: {rideData?.max_height_meters && (
- {rideData.max_height_meters}m +
)} {rideData?.max_speed_kmh && (
- {rideData.max_speed_kmh} km/h +
)} {rideData?.intensity_level && ( diff --git a/src/components/settings/SessionsTab.tsx b/src/components/settings/SessionsTab.tsx new file mode 100644 index 00000000..ede59601 --- /dev/null +++ b/src/components/settings/SessionsTab.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { useAuth } from '@/hooks/useAuth'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useToast } from '@/hooks/use-toast'; +import { Monitor, Smartphone, Tablet, Trash2 } from 'lucide-react'; +import { format } from 'date-fns'; + +interface UserSession { + id: string; + device_info: any; + last_activity: string; + created_at: string; + expires_at: string; + session_token: string; +} + +export function SessionsTab() { + const { user } = useAuth(); + const { toast } = useToast(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchSessions = async () => { + if (!user) return; + + const { data, error } = await supabase + .from('user_sessions') + .select('*') + .eq('user_id', user.id) + .order('last_activity', { ascending: false }); + + if (error) { + console.error('Error fetching sessions:', error); + } else { + setSessions(data || []); + } + setLoading(false); + }; + + useEffect(() => { + fetchSessions(); + }, [user]); + + const revokeSession = async (sessionId: string) => { + const { error } = await supabase + .from('user_sessions') + .delete() + .eq('id', sessionId); + + if (error) { + toast({ + title: 'Error', + description: 'Failed to revoke session', + variant: 'destructive' + }); + } else { + toast({ + title: 'Success', + description: 'Session revoked successfully' + }); + fetchSessions(); + } + }; + + const getDeviceIcon = (deviceInfo: any) => { + const ua = deviceInfo?.userAgent?.toLowerCase() || ''; + if (ua.includes('mobile')) return ; + if (ua.includes('tablet')) return ; + return ; + }; + + if (loading) { + return
Loading sessions...
; + } + + return ( +
+
+

Active Sessions

+

+ Manage your active login sessions across devices +

+
+ + {sessions.map((session) => ( + +
+
+ {getDeviceIcon(session.device_info)} +
+
+ {session.device_info?.browser || 'Unknown Browser'} +
+
+ Last active: {format(new Date(session.last_activity), 'PPpp')} +
+
+ Expires: {format(new Date(session.expires_at), 'PPpp')} +
+
+
+ +
+
+ ))} + + {sessions.length === 0 && ( + + No active sessions found + + )} +
+ ); +} diff --git a/src/components/ui/measurement-display.tsx b/src/components/ui/measurement-display.tsx index a3f066df..ce3937e6 100644 --- a/src/components/ui/measurement-display.tsx +++ b/src/components/ui/measurement-display.tsx @@ -1,9 +1,8 @@ import { useMemo } from 'react'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { - convertSpeed, - convertDistance, - convertHeight, + convertValueFromMetric, + getDisplayUnit, getSpeedUnit, getDistanceUnit, getHeightUnit, @@ -29,47 +28,36 @@ export function MeasurementDisplay({ const { displayValue, unit, alternateDisplay, tooltipText } = useMemo(() => { const system = unitPreferences.measurement_system; - let displayValue: number; - let unit: string; - let alternateValue: number; - let alternateUnit: string; + let metricUnit: string; + let displayUnit: string; + let alternateSystem: MeasurementSystem; switch (type) { case 'speed': - displayValue = convertSpeed(value, system); - unit = getSpeedUnit(system); - alternateValue = convertSpeed(value, system === 'metric' ? 'imperial' : 'metric'); - alternateUnit = getSpeedUnit(system === 'metric' ? 'imperial' : 'metric'); + metricUnit = 'km/h'; break; case 'distance': - displayValue = convertDistance(value, system); - unit = getDistanceUnit(system); - alternateValue = convertDistance(value, system === 'metric' ? 'imperial' : 'metric'); - alternateUnit = getDistanceUnit(system === 'metric' ? 'imperial' : 'metric'); + case 'short_distance': + metricUnit = 'm'; break; case 'height': - displayValue = convertHeight(value, system); - unit = getHeightUnit(system); - alternateValue = convertHeight(value, system === 'metric' ? 'imperial' : 'metric'); - alternateUnit = getHeightUnit(system === 'metric' ? 'imperial' : 'metric'); - break; - case 'short_distance': - displayValue = convertDistance(value, system); - unit = getShortDistanceUnit(system); - alternateValue = convertDistance(value, system === 'metric' ? 'imperial' : 'metric'); - alternateUnit = getShortDistanceUnit(system === 'metric' ? 'imperial' : 'metric'); + metricUnit = 'cm'; break; default: - displayValue = value; - unit = ''; - alternateValue = value; - alternateUnit = ''; + return { displayValue: value, unit: '', alternateDisplay: '', tooltipText: undefined }; } + alternateSystem = system === 'metric' ? 'imperial' : 'metric'; + displayUnit = getDisplayUnit(metricUnit, system); + const alternateUnit = getDisplayUnit(metricUnit, alternateSystem); + + const displayValue = convertValueFromMetric(value, displayUnit, metricUnit); + const alternateValue = convertValueFromMetric(value, alternateUnit, metricUnit); + const alternateDisplay = showBothUnits ? ` (${alternateValue} ${alternateUnit})` : ''; const tooltipText = showBothUnits ? undefined : `${alternateValue} ${alternateUnit}`; - return { displayValue, unit, alternateDisplay, tooltipText }; + return { displayValue, unit: displayUnit, alternateDisplay, tooltipText }; }, [value, type, unitPreferences.measurement_system, showBothUnits]); return ( diff --git a/src/components/upload/PhotoManagementDialog.tsx b/src/components/upload/PhotoManagementDialog.tsx index ee90b0dd..fbae7ad9 100644 --- a/src/components/upload/PhotoManagementDialog.tsx +++ b/src/components/upload/PhotoManagementDialog.tsx @@ -9,6 +9,16 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -43,6 +53,9 @@ export function PhotoManagementDialog({ const [photos, setPhotos] = useState([]); const [loading, setLoading] = useState(false); const [editingPhoto, setEditingPhoto] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [photoToDelete, setPhotoToDelete] = useState(null); + const [deleteReason, setDeleteReason] = useState(''); const { toast } = useToast(); useEffect(() => { @@ -77,55 +90,133 @@ export function PhotoManagementDialog({ - const deletePhoto = async (photoId: string) => { - if (!confirm('Are you sure you want to delete this photo?')) return; + const handleDeleteClick = (photo: Photo) => { + setPhotoToDelete(photo); + setDeleteReason(''); + setDeleteDialogOpen(true); + }; + + const requestPhotoDelete = async () => { + if (!photoToDelete || !deleteReason.trim()) return; try { - const { error } = await supabase.from('photos').delete().eq('id', photoId); + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); - if (error) throw error; + // Create content submission + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert([{ + user_id: user.id, + submission_type: 'photo_delete', + content: { + action: 'delete', + photo_id: photoToDelete.id + } + }]) + .select() + .single(); + + if (submissionError) throw submissionError; + + // Create submission item + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'photo_delete', + item_data: { + photo_id: photoToDelete.id, + entity_type: entityType, + entity_id: entityId, + cloudflare_image_url: photoToDelete.cloudflare_image_url, + caption: photoToDelete.caption, + reason: deleteReason + }, + status: 'pending' + }); + + if (itemError) throw itemError; - await fetchPhotos(); toast({ - title: 'Success', - description: 'Photo deleted', + title: 'Delete request submitted', + description: 'Your photo deletion request has been submitted for moderation', }); - onUpdate?.(); + setDeleteDialogOpen(false); + setPhotoToDelete(null); + setDeleteReason(''); + onOpenChange(false); } catch (error) { - console.error('Error deleting photo:', error); + console.error('Error requesting photo deletion:', error); toast({ title: 'Error', - description: 'Failed to delete photo', + description: 'Failed to submit deletion request', variant: 'destructive', }); } }; - const updatePhoto = async () => { + const requestPhotoEdit = async () => { if (!editingPhoto) return; try { - const { error } = await supabase - .from('photos') - .update({ - caption: editingPhoto.caption, - }) - .eq('id', editingPhoto.id); + // Get current user + const { data: { user } } = await supabase.auth.getUser(); + if (!user) throw new Error('Not authenticated'); - if (error) throw error; + // Get original photo data + const originalPhoto = photos.find(p => p.id === editingPhoto.id); + if (!originalPhoto) throw new Error('Original photo not found'); + + // Create content submission + const { data: submission, error: submissionError } = await supabase + .from('content_submissions') + .insert([{ + user_id: user.id, + submission_type: 'photo_edit', + content: { + action: 'edit', + photo_id: editingPhoto.id + } + }]) + .select() + .single(); + + if (submissionError) throw submissionError; + + // Create submission item + const { error: itemError } = await supabase + .from('submission_items') + .insert({ + submission_id: submission.id, + item_type: 'photo_edit', + item_data: { + photo_id: editingPhoto.id, + entity_type: entityType, + entity_id: entityId, + new_caption: editingPhoto.caption, + cloudflare_image_url: editingPhoto.cloudflare_image_url, + }, + original_data: { + caption: originalPhoto.caption, + }, + status: 'pending' + }); + + if (itemError) throw itemError; - await fetchPhotos(); setEditingPhoto(null); toast({ - title: 'Success', - description: 'Photo updated', + title: 'Edit request submitted', + description: 'Your photo edit has been submitted for moderation', }); - onUpdate?.(); + onOpenChange(false); } catch (error) { - console.error('Error updating photo:', error); + console.error('Error requesting photo edit:', error); toast({ title: 'Error', - description: 'Failed to update photo', + description: 'Failed to submit edit request', variant: 'destructive', }); } @@ -167,7 +258,7 @@ export function PhotoManagementDialog({ - + @@ -223,16 +314,16 @@ export function PhotoManagementDialog({ className="flex-1 sm:flex-initial" > - Edit + Request Edit
@@ -249,6 +340,44 @@ export function PhotoManagementDialog({ + + + + + Request Photo Deletion + + Please provide a reason for deleting this photo. This request will be reviewed by moderators. + + + +
+ +