This commit is contained in:
pacnpal
2025-10-04 14:34:37 +00:00
97 changed files with 6202 additions and 1347 deletions

View File

@@ -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<LocationResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
const [showResults, setShowResults] = useState(false);
const debouncedSearch = useDebounce(searchQuery, 500);
// Load initial location if editing
useEffect(() => {
if (initialLocationId) {
loadInitialLocation(initialLocationId);
}
}, [initialLocationId]);
const loadInitialLocation = async (locationId: string) => {
const { data, error } = await supabase
.from('locations')
.select('*')
.eq('id', locationId)
.maybeSingle();
if (data && !error) {
setSelectedLocation({
name: data.name,
city: data.city || undefined,
state_province: data.state_province || undefined,
country: data.country,
postal_code: data.postal_code || undefined,
latitude: parseFloat(data.latitude?.toString() || '0'),
longitude: parseFloat(data.longitude?.toString() || '0'),
timezone: data.timezone || undefined,
display_name: data.name, // Use name as display for existing locations
});
}
};
const searchLocations = useCallback(async (query: string) => {
if (!query || query.length < 3) {
setResults([]);
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 (
<div className={className}>
{!selectedLocation ? (
<div className="space-y-2">
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search for a location (city, address, landmark...)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
{showResults && results.length > 0 && (
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
<div className="divide-y">
{results.map((result) => (
<button
type="button"
key={result.place_id}
onClick={() => handleSelectResult(result)}
className="w-full text-left p-3 hover:bg-accent transition-colors"
>
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{result.display_name}</p>
<p className="text-xs text-muted-foreground">
{result.lat}, {result.lon}
</p>
</div>
</div>
</button>
))}
</div>
</Card>
)}
</div>
) : (
<div className="space-y-4">
<Card className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<MapPin className="h-5 w-5 text-primary mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium">{selectedLocation.name}</p>
<div className="text-sm text-muted-foreground space-y-1 mt-1">
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
<p>Country: {selectedLocation.country}</p>
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
<p className="text-xs">
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
</p>
</div>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleClear}
>
<X className="h-4 w-4" />
</Button>
</div>
</Card>
<ParkLocationMap
latitude={selectedLocation.latitude}
longitude={selectedLocation.longitude}
parkName={selectedLocation.name}
className="h-48"
/>
</div>
)}
</div>
);
}

View File

@@ -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 }:
</div>
</div>
{/* Location */}
<div className="space-y-2">
<Label>Location</Label>
<LocationSearch
onLocationSelect={(location) => {
setValue('location', location);
}}
initialLocationId={watch('location_id')}
/>
<p className="text-sm text-muted-foreground">
Search for the park's location using OpenStreetMap. Location will be created when submission is approved.
</p>
</div>
{/* Operator & Property Owner Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3>

View File

@@ -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",

View File

@@ -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 (
<Card>
<CardHeader>
@@ -101,7 +107,7 @@ export function RideModelForm({
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">

View File

@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -129,10 +158,28 @@ export function CoasterStatsEditor({
<Input
type="number"
step="0.01"
value={stat.stat_value}
onChange={(e) => 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' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(stat.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>
<Label className="text-xs">Unit</Label>

View File

@@ -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({
<div>
<Label className="text-xs">Value</Label>
<Input
value={spec.spec_value}
onChange={(e) => 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' && (
<p className="text-xs text-muted-foreground mt-1">
Enter in {getDisplayUnit(spec.unit, preferences.measurement_system)}
</p>
)}
</div>
<div>

View File

@@ -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 (

View File

@@ -96,7 +96,6 @@ export function UserListManager() {
description: newListDescription || null,
list_type: newListType,
is_public: newListIsPublic,
items: [], // Legacy field, will be empty
}])
.select()
.single();

View File

@@ -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 (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Edit className="h-3 w-3 mr-1" />
{fieldName} ({totalChanges} changes)
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{fieldName} ({differences.length} items, {totalChanges} changed)
</div>
{unchangedCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowUnchanged(!showUnchanged)}
className="h-6 text-xs"
>
{showUnchanged ? 'Hide' : 'Show'} {unchangedCount} unchanged
</Button>
)}
</div>
<div className="flex flex-col gap-1">
{differences.map((diff, idx) => {
if (diff.type === 'unchanged' && !showUnchanged) {
return null;
}
return (
<ArrayDiffItemDisplay key={idx} diff={diff} />
);
})}
</div>
</div>
);
}
function ArrayDiffItemDisplay({ diff }: { diff: ArrayDiffItem }) {
const isObject = typeof diff.newValue === 'object' || typeof diff.oldValue === 'object';
switch (diff.type) {
case 'added':
return (
<div className="flex items-start gap-2 p-2 rounded bg-green-500/10 border border-green-500/20">
<Plus className="h-4 w-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(diff.newValue)}
</span>
)}
</div>
</div>
);
case 'removed':
return (
<div className="flex items-start gap-2 p-2 rounded bg-red-500/10 border border-red-500/20">
<Minus className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.oldValue} className="line-through opacity-75" />
) : (
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(diff.oldValue)}
</span>
)}
</div>
</div>
);
case 'modified':
return (
<div className="flex flex-col gap-1 p-2 rounded bg-amber-500/10 border border-amber-500/20">
<div className="flex items-start gap-2">
<Edit className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="text-red-600 dark:text-red-400 line-through mb-1">
{isObject ? (
<ObjectDisplay value={diff.oldValue} />
) : (
formatFieldValue(diff.oldValue)
)}
</div>
<div className="text-green-600 dark:text-green-400">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
</div>
</div>
);
case 'unchanged':
return (
<div className="flex items-start gap-2 p-2 rounded bg-muted/20">
<Check className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm text-muted-foreground">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
);
}
}
function ObjectDisplay({ value, className = '' }: { value: any; className?: string }) {
if (!value || typeof value !== 'object') {
return <span className={className}>{formatFieldValue(value)}</span>;
}
return (
<div className={`space-y-0.5 ${className}`}>
{Object.entries(value).map(([key, val]) => (
<div key={key} className="flex gap-2">
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
<span>{formatFieldValue(val)}</span>
</div>
))}
</div>
);
}
/**
* 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;
}

View File

@@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
const [cardImageUrl, setCardImageUrl] = useState<string | null>(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 (
<div className="space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline">
Photo
</Badge>
<Badge variant={isEdit ? "secondary" : "destructive"}>
{isEdit ? 'Edit' : 'Delete'}
</Badge>
</div>
{itemData?.cloudflare_image_url && (
<Card className="overflow-hidden">
<CardContent className="p-2">
<img
src={itemData.cloudflare_image_url}
alt="Photo to be modified"
className="w-full h-32 object-cover rounded"
/>
</CardContent>
</Card>
)}
{isEdit && (
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">Old caption: </span>
<span className="text-muted-foreground">
{originalData?.caption || <em>No caption</em>}
</span>
</div>
<div>
<span className="font-medium">New caption: </span>
<span className="text-muted-foreground">
{itemData?.new_caption || <em>No caption</em>}
</span>
</div>
</div>
)}
{!isEdit && itemData?.reason && (
<div className="text-sm">
<span className="font-medium">Reason: </span>
<span className="text-muted-foreground">{itemData.reason}</span>
</div>
)}
<div className="text-xs text-muted-foreground italic">
Click "Review Items" for full details
</div>
</div>
);
}
// Build photos array for modal
const photos = [];
if (bannerImageUrl) {

View File

@@ -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 (
<ArrayFieldDiff
fieldName={formatFieldName(field)}
oldArray={oldValue}
newArray={newValue}
compact={compact}
/>
);
}
// 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 (
<Badge variant="outline" className={getChangeColor()}>
{formatFieldName(field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">{formatFieldName(field)}</div>
{changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(newValue)}
</div>
)}
{changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</div>
)}
{changeType === 'modified' && (
<div className="flex items-center gap-2 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(newValue)}
</span>
</div>
)}
</div>
);
}
interface ImageDiffProps {
change: ImageChange;
compact?: boolean;
}
export function ImageDiff({ change, compact = false }: ImageDiffProps) {
const { type, oldUrl, newUrl } = change;
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
{type === 'banner' ? 'Banner' : 'Card'} Image
</Badge>
);
}
// Determine scenario
const isAddition = !oldUrl && newUrl;
const isRemoval = oldUrl && !newUrl;
const isReplacement = oldUrl && newUrl;
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium">
{type === 'banner' ? 'Banner' : 'Card'} Image
{isAddition && <span className="text-green-600 dark:text-green-400 ml-2">(New)</span>}
{isRemoval && <span className="text-red-600 dark:text-red-400 ml-2">(Removed)</span>}
{isReplacement && <span className="text-amber-600 dark:text-amber-400 ml-2">(Changed)</span>}
</div>
<div className="flex items-center gap-3">
{oldUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">Before</div>
<img
src={oldUrl}
alt="Previous"
className="w-full h-32 object-cover rounded border-2 border-red-500/50"
loading="lazy"
/>
</div>
)}
{oldUrl && newUrl && (
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)}
{newUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{isAddition ? 'New Image' : 'After'}</div>
<img
src={newUrl}
alt="New"
className="w-full h-32 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/>
</div>
)}
</div>
</div>
);
}
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 (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Location
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">Location</div>
<div className="flex items-center gap-2 text-sm">
{oldLocation && (
<span className="text-red-600 dark:text-red-400 line-through">
{formatLocation(oldLocation)}
</span>
)}
{oldLocation && newLocation && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
{newLocation && (
<span className="text-green-600 dark:text-green-400">
{formatLocation(newLocation)}
</span>
)}
</div>
</div>
);
}

View File

@@ -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 (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.park_type && <Badge variant="outline">{data.park_type}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'ride':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.status && <Badge variant="outline">{data.status}</Badge>}
</div>
</div>
);
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
{data.founded_year && (
<Badge variant="outline">Founded {data.founded_year}</Badge>
)}
</div>
);
case 'ride_model':
return (
<div className="space-y-2">
<h4 className="font-semibold">{data.name}</h4>
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
<div className="flex gap-2 flex-wrap">
{data.category && <Badge variant="outline">{data.category}</Badge>}
{data.ride_type && <Badge variant="outline">{data.ride_type}</Badge>}
</div>
</div>
);
case 'photo':
return (
<div className="space-y-2">
{/* Fetch and display from photo_submission_items */}
<PhotoSubmissionDisplay submissionId={data.submission_id} />
</div>
);
default:
return (
<div className="text-sm text-muted-foreground">
No preview available
</div>
);
}
// Use detailed view for review manager with photo detection
return (
<SubmissionChangesDisplay
item={item}
view="detailed"
showImages={true}
submissionId={submissionId}
/>
);
};
return (

View File

@@ -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<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile();
const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all');
@@ -67,21 +70,28 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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<ModerationQueueRef>((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)}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white text-xs opacity-0 hover:opacity-100 transition-opacity rounded">
<Eye className="w-4 h-4" />
@@ -1254,21 +1202,29 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((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 = `
<div class="absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs">
<div>⚠️ Image failed to load</div>
<div class="mt-1 font-mono text-xs break-all px-2">${photo.url}</div>
</div>
`;
}
}}
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);
}
}}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 hover:opacity-100 transition-opacity rounded">
<Eye className="w-5 h-5" />
@@ -1468,13 +1424,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{item.content.ride?.max_speed_kmh && (
<div>
<span className="font-medium">Max Speed: </span>
<span className="text-muted-foreground">{item.content.ride.max_speed_kmh} km/h</span>
<span className="text-muted-foreground">
<MeasurementDisplay value={item.content.ride.max_speed_kmh} type="speed" className="inline" />
</span>
</div>
)}
{item.content.ride?.max_height_meters && (
<div>
<span className="font-medium">Max Height: </span>
<span className="text-muted-foreground">{item.content.ride.max_height_meters} m</span>
<span className="text-muted-foreground">
<MeasurementDisplay value={item.content.ride.max_height_meters} type="height" className="inline" />
</span>
</div>
)}
</div>
@@ -1487,10 +1447,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
item.submission_type === 'property_owner' ||
item.submission_type === 'park' ||
item.submission_type === 'ride') ? (
<EntityEditPreview
<SubmissionItemsList
submissionId={item.id}
entityType={item.submission_type}
entityName={item.content.name || item.entity_name}
view="summary"
showImages={true}
/>
) : (
<div>
@@ -1736,6 +1696,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<div className="space-y-4">
{/* Filter Bar */}
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
</div>
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>

View File

@@ -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 (
<Badge variant="outline" className="text-green-600 dark:text-green-400">
<ImageIcon className="h-3 w-3 mr-1" />
+{photos.length} Photo{photos.length > 1 ? 's' : ''}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium text-green-600 dark:text-green-400">
<ImageIcon className="h-4 w-4 inline mr-1" />
Adding {photos.length} Photo{photos.length > 1 ? 's' : ''}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{photos.slice(0, 6).map((photo, idx) => (
<div key={idx} className="flex flex-col gap-1">
<img
src={photo.url}
alt={photo.title || photo.caption || `Photo ${idx + 1}`}
className="w-full h-24 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/>
{(photo.title || photo.caption) && (
<div className="text-xs text-muted-foreground truncate">
{photo.title || photo.caption}
</div>
)}
</div>
))}
{photos.length > 6 && (
<div className="flex items-center justify-center h-24 bg-muted rounded border-2 border-dashed">
<span className="text-sm text-muted-foreground">
+{photos.length - 6} more
</span>
</div>
)}
</div>
</div>
);
}
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 (
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
<Edit className="h-3 w-3 mr-1" />
Photo Edit
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
<Edit className="h-4 w-4 inline mr-1" />
Photo Metadata Edit
</div>
<div className="flex gap-3">
<img
src={photo.url}
alt="Photo being edited"
className="w-32 h-32 object-cover rounded"
loading="lazy"
/>
<div className="flex-1 flex flex-col gap-2 text-sm">
{photo.oldTitle !== photo.newTitle && (
<div>
<div className="font-medium mb-1">Title:</div>
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldTitle || 'None'}</div>
<div className="text-green-600 dark:text-green-400">{photo.newTitle || 'None'}</div>
</div>
)}
{photo.oldCaption !== photo.newCaption && (
<div>
<div className="font-medium mb-1">Caption:</div>
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldCaption || 'None'}</div>
<div className="text-green-600 dark:text-green-400">{photo.newCaption || 'None'}</div>
</div>
)}
</div>
</div>
</div>
);
}
interface PhotoDeletionPreviewProps {
photo: {
url: string;
title?: string;
caption?: string;
};
compact?: boolean;
}
export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) {
if (compact) {
return (
<Badge variant="outline" className="text-red-600 dark:text-red-400">
<Trash2 className="h-3 w-3 mr-1" />
Delete Photo
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-destructive/10">
<div className="text-sm font-medium text-red-600 dark:text-red-400">
<Trash2 className="h-4 w-4 inline mr-1" />
Deleting Photo
</div>
<div className="flex gap-3">
<img
src={photo.url}
alt={photo.title || photo.caption || 'Photo to be deleted'}
className="w-32 h-32 object-cover rounded opacity-75"
loading="lazy"
/>
{(photo.title || photo.caption) && (
<div className="flex-1 text-sm">
{photo.title && <div className="font-medium">{photo.title}</div>}
{photo.caption && <div className="text-muted-foreground">{photo.caption}</div>}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<ReportsQueueRef>((props, ref) => {
const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(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() {
))}
</div>
);
}
});

View File

@@ -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 <SpeedFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('height') || fieldName.includes('length') ||
fieldName === 'max_height_meters' || fieldName === 'length_meters' ||
fieldName === 'drop_height_meters') {
return <MeasurementFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'status') {
return <StatusFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('date') && !fieldName.includes('updated') && !fieldName.includes('created')) {
return <DateFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('_id') && fieldName !== 'id' && fieldName !== 'user_id') {
return <RelationshipFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'latitude' || fieldName === 'longitude') {
return <CoordinateFieldDisplay change={change} compact={compact} />;
}
// Fallback to null, will be handled by regular FieldDiff
return null;
}
function SpeedFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Speed
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<SpeedDisplay kmh={change.newValue} />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <SpeedDisplay kmh={change.newValue} />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
)}
</div>
);
}
function MeasurementFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-purple-600 dark:text-purple-400">
Measurement
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<MeasurementDisplay value={change.newValue} type="distance" />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <MeasurementDisplay value={change.newValue} type="distance" />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
)}
</div>
);
}
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 (
<Badge variant="outline" className="text-indigo-600 dark:text-indigo-400">
Status
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3">
<Badge className={`${getStatusColor(change.oldValue)} line-through`}>
{formatFieldValue(change.oldValue)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
</div>
)}
{change.changeType === 'added' && (
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
)}
{change.changeType === 'removed' && (
<Badge className={`${getStatusColor(change.oldValue)} line-through opacity-75`}>
{formatFieldValue(change.oldValue)}
</Badge>
)}
</div>
);
}
function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-teal-600 dark:text-teal-400">
<Calendar className="h-3 w-3 mr-1" />
Date
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(change.newValue)}
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(change.newValue)}
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</div>
)}
</div>
);
}
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 (
<Badge variant="outline" className="text-cyan-600 dark:text-cyan-400">
{formatFieldName(change.field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm font-mono">
<span className="text-red-600 dark:text-red-400 line-through text-xs">
{String(change.oldValue).slice(0, 8)}...
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 text-xs">
{String(change.newValue).slice(0, 8)}...
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono text-xs">
+ {String(change.newValue).slice(0, 8)}...
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono text-xs">
{String(change.oldValue).slice(0, 8)}...
</div>
)}
</div>
);
}
function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-orange-600 dark:text-orange-400">
<MapPin className="h-3 w-3 mr-1" />
Coordinates
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 font-mono">
{Number(change.newValue).toFixed(6)}°
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono">
+ {Number(change.newValue).toFixed(6)}°
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</div>
)}
</div>
);
}

View File

@@ -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<ChangesSummary | null>(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 <Skeleton className="h-16 w-full" />;
}
// Get appropriate icon for entity type
const getEntityIcon = () => {
const iconClass = "h-4 w-4";
switch (item.item_type) {
case 'park': return <Building2 className={iconClass} />;
case 'ride': return <Train className={iconClass} />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building className={iconClass} />;
case 'photo': return <ImageIcon className={iconClass} />;
default: return <MapPin className={iconClass} />;
}
};
// Get action badge
const getActionBadge = () => {
switch (changes.action) {
case 'create':
return <Badge className="bg-green-600"><Plus className="h-3 w-3 mr-1" />New</Badge>;
case 'edit':
return <Badge className="bg-amber-600"><Edit className="h-3 w-3 mr-1" />Edit</Badge>;
case 'delete':
return <Badge variant="destructive"><Trash2 className="h-3 w-3 mr-1" />Delete</Badge>;
}
};
const magnitude = getChangeMagnitude(
changes.totalChanges,
changes.imageChanges.length > 0,
changes.action
);
if (view === 'summary') {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
{getEntityIcon()}
<span className="font-medium">{changes.entityName}</span>
{getActionBadge()}
{changes.action === 'edit' && (
<Badge variant={magnitude.variant} className="text-xs">
{magnitude.label} Change
</Badge>
)}
</div>
{changes.action === 'edit' && changes.totalChanges > 0 && (
<div className="flex flex-wrap gap-1">
{changes.fieldChanges.slice(0, 5).map((change, idx) => (
<FieldDiff key={idx} change={change} compact />
))}
{changes.imageChanges.map((change, idx) => (
<ImageDiff key={`img-${idx}`} change={change} compact />
))}
{changes.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={`photo-${idx}`} photos={change.photos} compact />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
return null;
})}
{changes.hasLocationChange && (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Location
</Badge>
)}
{changes.totalChanges > 5 && (
<Badge variant="outline">
+{changes.totalChanges - 5} more
</Badge>
)}
</div>
)}
{changes.action === 'create' && item.item_data?.description && (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.item_data.description}
</div>
)}
{changes.action === 'delete' && (
<div className="text-sm text-destructive">
Marked for deletion
</div>
)}
</div>
);
}
// Detailed view
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{getEntityIcon()}
<h3 className="text-lg font-semibold">{changes.entityName}</h3>
{getActionBadge()}
</div>
{changes.action === 'create' && (
<div className="text-sm text-muted-foreground">
Creating new {item.item_type}
</div>
)}
{changes.action === 'delete' && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
This {item.item_type} will be deleted
</div>
)}
{changes.action === 'edit' && changes.totalChanges > 0 && (
<>
{changes.fieldChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Field Changes ({changes.fieldChanges.length})</h4>
<div className="grid gap-2">
{changes.fieldChanges.map((change, idx) => (
<FieldDiff key={idx} change={change} />
))}
</div>
</div>
)}
{showImages && changes.imageChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Image Changes</h4>
<div className="grid gap-2">
{changes.imageChanges.map((change, idx) => (
<ImageDiff key={idx} change={change} />
))}
</div>
</div>
)}
{showImages && changes.photoChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Photo Changes</h4>
<div className="grid gap-2">
{changes.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={idx} photos={change.photos} compact={false} />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={idx} photo={change.photo} compact={false} />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={idx} photo={change.photo} compact={false} />;
}
return null;
})}
</div>
</div>
)}
{changes.hasLocationChange && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Location Change</h4>
<LocationDiff
oldLocation={item.original_data?.location || item.original_data?.location_id}
newLocation={item.item_data?.location || item.item_data?.location_id}
/>
</div>
)}
</>
)}
{changes.action === 'edit' && changes.totalChanges === 0 && (
<div className="text-sm text-muted-foreground">
No changes detected
</div>
)}
</div>
);
}

View File

@@ -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<SubmissionItemData[]>([]);
const [hasPhotos, setHasPhotos] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full" />
{view === 'detailed' && <Skeleton className="h-32 w-full" />}
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (items.length === 0 && !hasPhotos) {
return (
<div className="text-sm text-muted-foreground">
No items found for this submission
</div>
);
}
return (
<div className="flex flex-col gap-3">
{/* Show regular submission items */}
{items.map((item) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</div>
))}
{/* Show photo submission if exists */}
{hasPhotos && (
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<PhotoSubmissionDisplay submissionId={submissionId} />
</div>
)}
</div>
);
}

View File

@@ -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({
<ItemReviewCard
item={item}
onEdit={() => handleEdit(item)}
onStatusChange={(status) => handleItemStatusChange(item.id, status)}
onStatusChange={async () => {
// Status changes handled via approve/reject actions
await loadSubmissionItems();
}}
submissionId={submissionId}
/>
</div>
))}

View File

@@ -13,7 +13,7 @@ const OperatorCard = ({ company }: OperatorCardProps) => {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/operators/${company.slug}/parks/`);
navigate(`/operators/${company.slug}`);
};
const getCompanyIcon = () => {

View File

@@ -13,7 +13,7 @@ const ParkOwnerCard = ({ company }: ParkOwnerCardProps) => {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/owners/${company.slug}/parks/`);
navigate(`/owners/${company.slug}`);
};
const getCompanyIcon = () => {

View File

@@ -59,7 +59,7 @@ export function ParkCard({ park }: ParkCardProps) {
)}
{/* Gradient Overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent group-hover:scale-110 transition-transform duration-500" />
{/* Status Badge */}
<Badge className={`absolute top-3 right-3 ${getStatusColor(park.status)} border`}>

View File

@@ -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<typeof reviewSchema>;
@@ -189,7 +196,8 @@ export function ReviewForm({
{entityType === 'ride' && <div className="space-y-2">
<Label htmlFor="wait_time">Wait Time (minutes)</Label>
<Input id="wait_time" type="number" min="0" placeholder="How long did you wait?" {...register('wait_time_minutes', {
valueAsNumber: true
valueAsNumber: true,
setValueAs: (v) => v === '' || isNaN(v) ? undefined : v
})} />
</div>}

View File

@@ -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 });

View File

@@ -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) => {
)}
</div>
<span className="text-sm font-semibold">
{stat.stat_value.toLocaleString()}
{stat.unit && ` ${stat.unit}`}
{getDisplayValue(stat)}
</span>
</div>
))}

View File

@@ -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';
}
};

View File

@@ -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: <Zap className="w-5 h-5 text-amber-500" />,
label: 'High Speed',
value: `${ride.max_speed_kmh} km/h`
value: <MeasurementDisplay value={ride.max_speed_kmh} type="speed" className="inline" />
});
}
@@ -29,7 +30,7 @@ export function RideHighlights({ ride }: RideHighlightsProps) {
highlights.push({
icon: <TrendingUp className="w-5 h-5 text-blue-500" />,
label: 'Tall Structure',
value: `${ride.max_height_meters}m high`
value: <MeasurementDisplay value={ride.max_height_meters} type="height" className="inline" />
});
}

View File

@@ -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 (
<Card className="overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group">
<div
className="aspect-video bg-gradient-to-br from-primary/10 via-secondary/10 to-accent/10 relative overflow-hidden"
>
{((model as any).card_image_url || (model as any).card_image_id) ? (
<img
src={(model as any).card_image_url || `https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/${(model as any).card_image_id}/public`}
alt={model.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="flex items-center justify-center h-full">
<FerrisWheel className="w-16 h-16 text-muted-foreground/30" />
</div>
)}
</div>
<CardContent className="p-4 space-y-3">
<div>
<h3 className="font-semibold text-lg line-clamp-1 group-hover:text-primary transition-colors">
{model.name}
</h3>
{model.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{model.description}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
{formatCategory(model.category)}
</Badge>
<Badge variant="outline" className="text-xs">
{formatRideType(model.ride_type)}
</Badge>
</div>
<div className="pt-2 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{rideCount} {rideCount === 1 ? 'ride' : 'rides'}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/rides?model=${model.slug}`)}
>
View Rides
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,5 +0,0 @@
/**
* @deprecated Use EntityPhotoGallery directly or import from RidePhotoGalleryWrapper
* This file is kept for backwards compatibility
*/
export { RidePhotoGallery } from './RidePhotoGalleryWrapper';

View File

@@ -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 (
<EntityPhotoGallery
entityId={rideId}
entityType="ride"
entityName={rideName}
parentId={parkId}
/>
);
}

View File

@@ -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
>
<span className="text-sm font-medium">{spec.spec_name}</span>
<span className="text-sm text-muted-foreground">
{spec.spec_value}
{spec.unit && ` ${spec.unit}`}
{getDisplayValue(spec)}
</span>
</div>
))}

View File

@@ -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;
}
}

View File

@@ -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 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Ruler className="w-3 h-3" />
<span>{rideData.max_height_meters}m</span>
<MeasurementDisplay value={rideData.max_height_meters} type="height" className="inline" />
</div>
)}
{rideData?.max_speed_kmh && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Gauge className="w-3 h-3" />
<span>{rideData.max_speed_kmh} km/h</span>
<MeasurementDisplay value={rideData.max_speed_kmh} type="speed" className="inline" />
</div>
)}
{rideData?.intensity_level && (

View File

@@ -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<UserSession[]>([]);
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 <Smartphone className="w-4 h-4" />;
if (ua.includes('tablet')) return <Tablet className="w-4 h-4" />;
return <Monitor className="w-4 h-4" />;
};
if (loading) {
return <div className="text-muted-foreground">Loading sessions...</div>;
}
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Active Sessions</h3>
<p className="text-sm text-muted-foreground">
Manage your active login sessions across devices
</p>
</div>
{sessions.map((session) => (
<Card key={session.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex gap-3">
{getDeviceIcon(session.device_info)}
<div>
<div className="font-medium">
{session.device_info?.browser || 'Unknown Browser'}
</div>
<div className="text-sm text-muted-foreground">
Last active: {format(new Date(session.last_activity), 'PPpp')}
</div>
<div className="text-sm text-muted-foreground">
Expires: {format(new Date(session.expires_at), 'PPpp')}
</div>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => revokeSession(session.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
Revoke
</Button>
</div>
</Card>
))}
{sessions.length === 0 && (
<Card className="p-8 text-center text-muted-foreground">
No active sessions found
</Card>
)}
</div>
);
}

View File

@@ -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 (

View File

@@ -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<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [photoToDelete, setPhotoToDelete] = useState<Photo | null>(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({
<Button variant="outline" onClick={() => setEditingPhoto(null)}>
Cancel
</Button>
<Button onClick={updatePhoto}>Save Changes</Button>
<Button onClick={requestPhotoEdit}>Submit for Review</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -223,16 +314,16 @@ export function PhotoManagementDialog({
className="flex-1 sm:flex-initial"
>
<Pencil className="w-4 h-4 mr-2" />
Edit
Request Edit
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => deletePhoto(photo.id)}
onClick={() => handleDeleteClick(photo)}
className="flex-1 sm:flex-initial"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
Request Delete
</Button>
</div>
</div>
@@ -249,6 +340,44 @@ export function PhotoManagementDialog({
</Button>
</DialogFooter>
</DialogContent>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Request Photo Deletion</AlertDialogTitle>
<AlertDialogDescription>
Please provide a reason for deleting this photo. This request will be reviewed by moderators.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-reason">Reason for deletion</Label>
<Textarea
id="delete-reason"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder="Please explain why this photo should be deleted..."
rows={3}
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
}}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={requestPhotoDelete}
disabled={!deleteReason.trim()}
>
Submit Request
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@@ -1,296 +0,0 @@
/**
* @deprecated This component is deprecated. Use UppyPhotoSubmissionUpload instead.
* This file is kept for backwards compatibility only.
*
* For new implementations, use:
* - UppyPhotoSubmissionUpload for direct uploads
* - EntityPhotoGallery for entity-specific photo galleries
*/
import { useState } from 'react';
import { Upload, X, Camera } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/hooks/use-toast';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
interface PhotoSubmissionUploadProps {
onSubmissionComplete?: () => void;
parkId?: string;
rideId?: string;
}
export function PhotoSubmissionUpload({ onSubmissionComplete, parkId, rideId }: PhotoSubmissionUploadProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploading, setUploading] = useState(false);
const [caption, setCaption] = useState('');
const [title, setTitle] = useState('');
const { toast } = useToast();
const { user } = useAuth();
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const imageFiles = files.filter(file => file.type.startsWith('image/'));
if (imageFiles.length !== files.length) {
toast({
title: "Invalid Files",
description: "Only image files are allowed",
variant: "destructive",
});
}
setSelectedFiles(prev => [...prev, ...imageFiles].slice(0, 5)); // Max 5 files
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
};
const uploadFileToCloudflare = async (file: File): Promise<{ id: string; url: string }> => {
try {
// Get upload URL from Supabase edge function
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
method: 'POST',
});
if (uploadError || !uploadData?.uploadURL) {
console.error('Failed to get upload URL:', uploadError);
throw new Error('Failed to get upload URL');
}
// Upload file directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadData.uploadURL, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('Cloudflare upload failed:', errorText);
throw new Error('Failed to upload file to Cloudflare');
}
const result = await uploadResponse.json();
const imageId = result.result?.id;
if (!imageId) {
console.error('No image ID returned from Cloudflare:', result);
throw new Error('Invalid response from Cloudflare');
}
// Get the delivery URL using the edge function with URL parameters
const getImageUrl = new URL(`https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image`);
getImageUrl.searchParams.set('id', imageId);
const response = await fetch(getImageUrl.toString(), {
method: 'GET',
headers: {
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4`,
'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to get image status:', errorText);
throw new Error('Failed to get image URL');
}
const statusData = await response.json();
if (!statusData?.urls?.public) {
console.error('No image URL returned:', statusData);
throw new Error('No image URL available');
}
return {
id: imageId,
url: statusData.urls.public,
};
} catch (error) {
console.error('Upload error:', error);
throw error;
}
};
const handleSubmit = async () => {
if (!user) {
toast({
title: "Authentication Required",
description: "Please log in to submit photos",
variant: "destructive",
});
return;
}
if (selectedFiles.length === 0) {
toast({
title: "No Files Selected",
description: "Please select at least one image to submit",
variant: "destructive",
});
return;
}
setUploading(true);
try {
// Upload files to Cloudflare Images first
const photoSubmissions = await Promise.all(
selectedFiles.map(async (file, index) => {
const uploadResult = await uploadFileToCloudflare(file);
return {
filename: file.name,
size: file.size,
type: file.type,
url: uploadResult.url,
imageId: uploadResult.id,
caption: index === 0 ? caption : '', // Only first image gets the caption
};
})
);
// Submit to content_submissions table
const { error } = await supabase
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: 'photo',
content: {
photos: photoSubmissions,
title: title.trim() || undefined,
caption: caption.trim() || undefined,
park_id: parkId,
ride_id: rideId,
context: parkId ? 'park' : rideId ? 'ride' : 'general',
},
status: 'pending',
});
if (error) throw error;
toast({
title: "Photos Submitted",
description: "Your photos have been submitted for moderation review",
});
// Reset form
setSelectedFiles([]);
setCaption('');
setTitle('');
onSubmissionComplete?.();
} catch (error) {
console.error('Error submitting photos:', error);
toast({
title: "Submission Failed",
description: "Failed to submit photos. Please try again.",
variant: "destructive",
});
} finally {
setUploading(false);
}
};
return (
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-2 mb-4">
<Camera className="w-5 h-5" />
<h3 className="text-lg font-semibold">Submit Photos</h3>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="photo-title">Title (optional)</Label>
<Input
id="photo-title"
placeholder="Give your photo submission a title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={100}
/>
</div>
<div>
<Label htmlFor="photo-caption">Caption (optional)</Label>
<Textarea
id="photo-caption"
placeholder="Add a caption to describe your photos"
value={caption}
onChange={(e) => setCaption(e.target.value)}
rows={3}
maxLength={500}
/>
</div>
<div>
<Label htmlFor="photo-upload">Select Photos</Label>
<div className="mt-2">
<input
id="photo-upload"
type="file"
accept="image/*"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('photo-upload')?.click()}
className="w-full"
disabled={selectedFiles.length >= 5}
>
<Upload className="w-4 h-4 mr-2" />
Choose Photos {selectedFiles.length > 0 && `(${selectedFiles.length}/5)`}
</Button>
</div>
</div>
{selectedFiles.length > 0 && (
<div className="space-y-3">
<Label>Selected Photos:</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{selectedFiles.map((file, index) => (
<div key={index} className="relative">
<img
src={URL.createObjectURL(file)}
alt={`Preview ${index + 1}`}
className="w-full h-24 object-cover rounded border"
/>
<Button
type="button"
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
onClick={() => removeFile(index)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
)}
<Button
onClick={handleSubmit}
disabled={uploading || selectedFiles.length === 0}
className="w-full"
>
{uploading ? 'Submitting...' : 'Submit Photos for Review'}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -281,12 +281,9 @@ export function PhotoUpload({
console.error('Failed to load avatar image:', uploadedImages[0].thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Avatar image loaded successfully:', uploadedImages[0].thumbnailUrl);
}}
/>
) : existingPhotos.length > 0 ? (
<img
<img
src={existingPhotos[0]}
alt="Avatar"
className="w-24 h-24 rounded-full object-cover border-2 border-border"
@@ -432,9 +429,6 @@ export function PhotoUpload({
console.error('Failed to load image:', image.thumbnailUrl);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Image loaded successfully:', image.thumbnailUrl);
}}
/>
<Button
variant="destructive"
@@ -470,9 +464,6 @@ export function PhotoUpload({
console.error('Failed to load existing image:', url);
e.currentTarget.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSIjZjNmNGY2Ii8+CjxwYXRoIGQ9Im0xNSAxMi0zLTMtMy4wMDEgM0w2IDlsNi02aDZ2NloiIGZpbGw9IiM5Y2EzYWYiLz4KPC9zdmc+';
}}
onLoad={() => {
console.log('Existing image loaded successfully:', url);
}}
/>
<Badge variant="outline" className="absolute bottom-2 left-2 text-xs">
Existing

View File

@@ -20,14 +20,7 @@ export function UppyPhotoSubmissionUpload({
entityId,
entityType,
parentId,
// Legacy props (deprecated)
parkId,
rideId,
}: UppyPhotoSubmissionUploadProps) {
// Support legacy props
const finalEntityId = entityId || rideId || parkId || '';
const finalEntityType = entityType || (rideId ? 'ride' : parkId ? 'park' : 'ride');
const finalParentId = parentId || (rideId ? parkId : undefined);
const [title, setTitle] = useState('');
const [photos, setPhotos] = useState<PhotoWithCaption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -203,9 +196,9 @@ export function UppyPhotoSubmissionUpload({
.from('photo_submissions')
.insert({
submission_id: submissionData.id,
entity_type: finalEntityType,
entity_id: finalEntityId,
parent_id: finalParentId || null,
entity_type: entityType,
entity_id: entityId,
parent_id: parentId || null,
title: title.trim() || null,
})
.select()
@@ -239,8 +232,8 @@ export function UppyPhotoSubmissionUpload({
console.log('✅ Photo submission created:', {
submission_id: submissionData.id,
photo_submission_id: photoSubmissionData.id,
entity_type: finalEntityType,
entity_id: finalEntityId,
entity_type: entityType,
entity_id: entityId,
photo_count: photoItems.length,
});
@@ -285,12 +278,9 @@ export function UppyPhotoSubmissionUpload({
const metadata = {
submissionType: 'photo',
entityId: finalEntityId,
entityType: finalEntityType,
parentId: finalParentId,
// Legacy support
parkId: finalEntityType === 'park' ? finalEntityId : finalParentId,
rideId: finalEntityType === 'ride' ? finalEntityId : undefined,
entityId,
entityType,
parentId,
userId: user?.id,
};