mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2026-03-25 10:01:45 -04:00
Merge branch 'main' of https://github.com/pacnpal/thrilltrack-explorer
This commit is contained in:
254
src/components/admin/LocationSearch.tsx
Normal file
254
src/components/admin/LocationSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -96,7 +96,6 @@ export function UserListManager() {
|
||||
description: newListDescription || null,
|
||||
list_type: newListType,
|
||||
is_public: newListIsPublic,
|
||||
items: [], // Legacy field, will be empty
|
||||
}])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
218
src/components/moderation/ArrayFieldDiff.tsx
Normal file
218
src/components/moderation/ArrayFieldDiff.tsx
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
191
src/components/moderation/FieldComparison.tsx
Normal file
191
src/components/moderation/FieldComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
159
src/components/moderation/PhotoComparison.tsx
Normal file
159
src/components/moderation/PhotoComparison.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal file
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal file
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/moderation/SubmissionItemsList.tsx
Normal file
112
src/components/moderation/SubmissionItemsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -13,7 +13,7 @@ const OperatorCard = ({ company }: OperatorCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/operators/${company.slug}/parks/`);
|
||||
navigate(`/operators/${company.slug}`);
|
||||
};
|
||||
|
||||
const getCompanyIcon = () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const ParkOwnerCard = ({ company }: ParkOwnerCardProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/owners/${company.slug}/parks/`);
|
||||
navigate(`/owners/${company.slug}`);
|
||||
};
|
||||
|
||||
const getCompanyIcon = () => {
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
84
src/components/rides/RideModelCard.tsx
Normal file
84
src/components/rides/RideModelCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* @deprecated Use EntityPhotoGallery directly or import from RidePhotoGalleryWrapper
|
||||
* This file is kept for backwards compatibility
|
||||
*/
|
||||
export { RidePhotoGallery } from './RidePhotoGalleryWrapper';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
123
src/components/settings/SessionsTab.tsx
Normal file
123
src/components/settings/SessionsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user