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

View File

@@ -14,12 +14,17 @@ import RideDetail from "./pages/RideDetail";
import Rides from "./pages/Rides"; import Rides from "./pages/Rides";
import Manufacturers from "./pages/Manufacturers"; import Manufacturers from "./pages/Manufacturers";
import ManufacturerDetail from "./pages/ManufacturerDetail"; import ManufacturerDetail from "./pages/ManufacturerDetail";
import ManufacturerRides from "./pages/ManufacturerRides";
import ManufacturerModels from "./pages/ManufacturerModels";
import Designers from "./pages/Designers"; import Designers from "./pages/Designers";
import DesignerDetail from "./pages/DesignerDetail"; import DesignerDetail from "./pages/DesignerDetail";
import DesignerRides from "./pages/DesignerRides";
import ParkOwners from "./pages/ParkOwners"; import ParkOwners from "./pages/ParkOwners";
import PropertyOwnerDetail from "./pages/PropertyOwnerDetail"; import PropertyOwnerDetail from "./pages/PropertyOwnerDetail";
import OwnerParks from "./pages/OwnerParks";
import Operators from "./pages/Operators"; import Operators from "./pages/Operators";
import OperatorDetail from "./pages/OperatorDetail"; import OperatorDetail from "./pages/OperatorDetail";
import OperatorParks from "./pages/OperatorParks";
import Auth from "./pages/Auth"; import Auth from "./pages/Auth";
import Profile from "./pages/Profile"; import Profile from "./pages/Profile";
import UserSettings from "./pages/UserSettings"; import UserSettings from "./pages/UserSettings";
@@ -51,12 +56,17 @@ function AppContent() {
<Route path="/search" element={<Search />} /> <Route path="/search" element={<Search />} />
<Route path="/manufacturers" element={<Manufacturers />} /> <Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/manufacturers/:slug" element={<ManufacturerDetail />} /> <Route path="/manufacturers/:slug" element={<ManufacturerDetail />} />
<Route path="/manufacturers/:manufacturerSlug/rides" element={<ManufacturerRides />} />
<Route path="/manufacturers/:manufacturerSlug/models" element={<ManufacturerModels />} />
<Route path="/designers" element={<Designers />} /> <Route path="/designers" element={<Designers />} />
<Route path="/designers/:slug" element={<DesignerDetail />} /> <Route path="/designers/:slug" element={<DesignerDetail />} />
<Route path="/designers/:designerSlug/rides" element={<DesignerRides />} />
<Route path="/owners" element={<ParkOwners />} /> <Route path="/owners" element={<ParkOwners />} />
<Route path="/owners/:slug" element={<PropertyOwnerDetail />} /> <Route path="/owners/:slug" element={<PropertyOwnerDetail />} />
<Route path="/owners/:ownerSlug/parks" element={<OwnerParks />} />
<Route path="/operators" element={<Operators />} /> <Route path="/operators" element={<Operators />} />
<Route path="/operators/:slug" element={<OperatorDetail />} /> <Route path="/operators/:slug" element={<OperatorDetail />} />
<Route path="/operators/:operatorSlug/parks" element={<OperatorParks />} />
<Route path="/auth" element={<Auth />} /> <Route path="/auth" element={<Auth />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/profile/:username" element={<Profile />} /> <Route path="/profile/:username" element={<Profile />} />

View File

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

View File

@@ -18,6 +18,7 @@ import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData'; import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { LocationSearch } from './LocationSearch';
const parkSchema = z.object({ const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'), 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'), status: z.string().min(1, 'Status is required'),
opening_date: z.string().optional(), opening_date: z.string().optional(),
closing_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('')), website_url: z.string().url().optional().or(z.literal('')),
phone: z.string().optional(), phone: z.string().optional(),
email: z.string().email().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')),
@@ -118,6 +131,7 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
status: initialData?.status || 'Operating', status: initialData?.status || 'Operating',
opening_date: initialData?.opening_date || '', opening_date: initialData?.opening_date || '',
closing_date: initialData?.closing_date || '', closing_date: initialData?.closing_date || '',
location_id: (initialData as any)?.location_id || undefined,
website_url: initialData?.website_url || '', website_url: initialData?.website_url || '',
phone: initialData?.phone || '', phone: initialData?.phone || '',
email: initialData?.email || '', email: initialData?.email || '',
@@ -281,6 +295,20 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
</div> </div>
</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 */} {/* Operator & Property Owner Selection */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold">Operator & Property Owner</h3> <h3 className="text-lg font-semibold">Operator & Property Owner</h3>

View File

@@ -25,12 +25,9 @@ import { TechnicalSpecsEditor } from './editors/TechnicalSpecsEditor';
import { CoasterStatsEditor } from './editors/CoasterStatsEditor'; import { CoasterStatsEditor } from './editors/CoasterStatsEditor';
import { FormerNamesEditor } from './editors/FormerNamesEditor'; import { FormerNamesEditor } from './editors/FormerNamesEditor';
import { import {
convertSpeed, convertValueToMetric,
convertDistance, convertValueFromMetric,
convertHeight, getDisplayUnit,
convertSpeedToMetric,
convertDistanceToMetric,
convertHeightToMetric,
getSpeedUnit, getSpeedUnit,
getDistanceUnit, getDistanceUnit,
getHeightUnit getHeightUnit
@@ -59,9 +56,6 @@ const rideSchema = z.object({
intensity_level: z.string().optional(), intensity_level: z.string().optional(),
drop_height_meters: z.number().optional(), drop_height_meters: z.number().optional(),
max_g_force: 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 and model
manufacturer_id: z.string().uuid().optional(), manufacturer_id: z.string().uuid().optional(),
ride_model_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 || '', closing_date: initialData?.closing_date || '',
// Convert metric values to user's preferred unit for display // Convert metric values to user's preferred unit for display
height_requirement: initialData?.height_requirement height_requirement: initialData?.height_requirement
? convertHeight(initialData.height_requirement, measurementSystem) ? convertValueFromMetric(initialData.height_requirement, getDisplayUnit('cm', measurementSystem), 'cm')
: undefined, : undefined,
age_requirement: initialData?.age_requirement || undefined, age_requirement: initialData?.age_requirement || undefined,
capacity_per_hour: initialData?.capacity_per_hour || undefined, capacity_per_hour: initialData?.capacity_per_hour || undefined,
duration_seconds: initialData?.duration_seconds || undefined, duration_seconds: initialData?.duration_seconds || undefined,
max_speed_kmh: initialData?.max_speed_kmh 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, : undefined,
max_height_meters: initialData?.max_height_meters max_height_meters: initialData?.max_height_meters
? convertDistance(initialData.max_height_meters, measurementSystem) ? convertValueFromMetric(initialData.max_height_meters, getDisplayUnit('m', measurementSystem), 'm')
: undefined, : undefined,
length_meters: initialData?.length_meters length_meters: initialData?.length_meters
? convertDistance(initialData.length_meters, measurementSystem) ? convertValueFromMetric(initialData.length_meters, getDisplayUnit('m', measurementSystem), 'm')
: undefined, : undefined,
inversions: initialData?.inversions || undefined, inversions: initialData?.inversions || undefined,
coaster_type: initialData?.coaster_type || undefined, coaster_type: initialData?.coaster_type || undefined,
seating_type: initialData?.seating_type || undefined, seating_type: initialData?.seating_type || undefined,
intensity_level: initialData?.intensity_level || undefined, intensity_level: initialData?.intensity_level || undefined,
drop_height_meters: initialData?.drop_height_meters drop_height_meters: initialData?.drop_height_meters
? convertDistance(initialData.drop_height_meters, measurementSystem) ? convertValueFromMetric(initialData.drop_height_meters, getDisplayUnit('m', measurementSystem), 'm')
: undefined, : undefined,
max_g_force: initialData?.max_g_force || 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, manufacturer_id: initialData?.manufacturer_id || undefined,
ride_model_id: initialData?.ride_model_id || undefined, ride_model_id: initialData?.ride_model_id || undefined,
images: { uploaded: [] } images: { uploaded: [] }
@@ -245,52 +236,30 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
...data, ...data,
status: dbStatus, status: dbStatus,
height_requirement: data.height_requirement height_requirement: data.height_requirement
? convertHeightToMetric(data.height_requirement, measurementSystem) ? convertValueToMetric(data.height_requirement, getDisplayUnit('cm', measurementSystem))
: undefined, : undefined,
max_speed_kmh: data.max_speed_kmh max_speed_kmh: data.max_speed_kmh
? convertSpeedToMetric(data.max_speed_kmh, measurementSystem) ? convertValueToMetric(data.max_speed_kmh, getDisplayUnit('km/h', measurementSystem))
: undefined, : undefined,
max_height_meters: data.max_height_meters max_height_meters: data.max_height_meters
? convertDistanceToMetric(data.max_height_meters, measurementSystem) ? convertValueToMetric(data.max_height_meters, getDisplayUnit('m', measurementSystem))
: undefined, : undefined,
length_meters: data.length_meters length_meters: data.length_meters
? convertDistanceToMetric(data.length_meters, measurementSystem) ? convertValueToMetric(data.length_meters, getDisplayUnit('m', measurementSystem))
: undefined, : undefined,
drop_height_meters: data.drop_height_meters drop_height_meters: data.drop_height_meters
? convertDistanceToMetric(data.drop_height_meters, measurementSystem) ? convertValueToMetric(data.drop_height_meters, getDisplayUnit('m', measurementSystem))
: undefined, : undefined,
// Add structured data from advanced editors // Pass relational data for proper handling
technical_specs: JSON.stringify(technicalSpecs), _technical_specifications: technicalSpecs,
coaster_stats: JSON.stringify(coasterStats), _coaster_statistics: coasterStats,
former_names: JSON.stringify(formerNames) _name_history: formerNames,
_tempNewManufacturer: tempNewManufacturer,
_tempNewRideModel: tempNewRideModel
}; };
// Build composite submission if new entities were created // Pass clean data to parent
const submissionContent: any = { await onSubmit(metricData as 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);
toast({ toast({
title: isEditing ? "Ride Updated" : "Submission Sent", title: isEditing ? "Ride Updated" : "Submission Sent",

View File

@@ -21,7 +21,6 @@ const rideModelSchema = z.object({
category: z.string().min(1, 'Category is required'), category: z.string().min(1, 'Category is required'),
ride_type: z.string().min(1, 'Ride type is required'), ride_type: z.string().min(1, 'Ride type is required'),
description: z.string().optional(), description: z.string().optional(),
technical_specs: z.string().optional(),
images: z.object({ images: z.object({
uploaded: z.array(z.object({ uploaded: z.array(z.object({
url: z.string(), url: z.string(),
@@ -82,12 +81,19 @@ export function RideModelForm({
category: initialData?.category || '', category: initialData?.category || '',
ride_type: initialData?.ride_type || '', ride_type: initialData?.ride_type || '',
description: initialData?.description || '', description: initialData?.description || '',
technical_specs: initialData?.technical_specs || '',
images: initialData?.images || { uploaded: [] } images: initialData?.images || { uploaded: [] }
} }
}); });
const handleFormSubmit = (data: RideModelFormData) => {
// Include relational technical specs
onSubmit({
...data,
_technical_specifications: technicalSpecs
} as any);
};
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -101,7 +107,7 @@ export function RideModelForm({
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -5,6 +5,14 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
interface CoasterStat { interface CoasterStat {
stat_name: string; stat_name: string;
@@ -38,6 +46,7 @@ export function CoasterStatsEditor({
onChange, onChange,
categories = DEFAULT_CATEGORIES categories = DEFAULT_CATEGORIES
}: CoasterStatsEditorProps) { }: CoasterStatsEditorProps) {
const { preferences } = useUnitPreferences();
const addStat = () => { const addStat = () => {
onChange([ onChange([
@@ -78,6 +87,26 @@ export function CoasterStatsEditor({
onChange(newStats); 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -129,10 +158,28 @@ export function CoasterStatsEditor({
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
value={stat.stat_value} value={getDisplayValue(stat)}
onChange={(e) => updateStat(index, 'stat_value', parseFloat(e.target.value) || 0)} 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" 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>
<div> <div>
<Label className="text-xs">Unit</Label> <Label className="text-xs">Unit</Label>

View File

@@ -4,6 +4,14 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import {
convertValueToMetric,
convertValueFromMetric,
detectUnitType,
getMetricUnit,
getDisplayUnit
} from "@/lib/units";
interface TechnicalSpec { interface TechnicalSpec {
spec_name: string; spec_name: string;
@@ -30,6 +38,7 @@ export function TechnicalSpecsEditor({
categories = DEFAULT_CATEGORIES, categories = DEFAULT_CATEGORIES,
commonSpecs = [] commonSpecs = []
}: TechnicalSpecsEditorProps) { }: TechnicalSpecsEditorProps) {
const { preferences } = useUnitPreferences();
const addSpec = () => { const addSpec = () => {
onChange([ onChange([
@@ -57,6 +66,26 @@ export function TechnicalSpecsEditor({
onChange(newSpecs); 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') => { const moveSpec = (index: number, direction: 'up' | 'down') => {
if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) { if ((direction === 'up' && index === 0) || (direction === 'down' && index === specs.length - 1)) {
return; return;
@@ -107,11 +136,30 @@ export function TechnicalSpecsEditor({
<div> <div>
<Label className="text-xs">Value</Label> <Label className="text-xs">Value</Label>
<Input <Input
value={spec.spec_value} value={getDisplayValue(spec)}
onChange={(e) => updateSpec(index, 'spec_value', e.target.value)} 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" placeholder="Value"
type={spec.spec_type === 'number' ? 'number' : 'text'} 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>
<div> <div>

View File

@@ -33,14 +33,7 @@ export function HeroSearch() {
]; ];
const handleSearch = () => { const handleSearch = () => {
if (!searchTerm.trim()) return; // Search functionality handled by AutocompleteSearch component in Header
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()}`);
}; };
return ( return (

View File

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

View File

@@ -0,0 +1,218 @@
import { Badge } from '@/components/ui/badge';
import { Plus, Minus, Edit, Check } from 'lucide-react';
import { formatFieldValue } from '@/lib/submissionChangeDetection';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
interface ArrayFieldDiffProps {
fieldName: string;
oldArray: any[];
newArray: any[];
compact?: boolean;
}
interface ArrayDiffItem {
type: 'added' | 'removed' | 'modified' | 'unchanged';
oldValue?: any;
newValue?: any;
index: number;
}
export function ArrayFieldDiff({ fieldName, oldArray, newArray, compact = false }: ArrayFieldDiffProps) {
const [showUnchanged, setShowUnchanged] = useState(false);
// Compute array differences
const differences = computeArrayDiff(oldArray || [], newArray || []);
const changedItems = differences.filter(d => d.type !== 'unchanged');
const unchangedCount = differences.filter(d => d.type === 'unchanged').length;
const totalChanges = changedItems.length;
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Edit className="h-3 w-3 mr-1" />
{fieldName} ({totalChanges} changes)
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{fieldName} ({differences.length} items, {totalChanges} changed)
</div>
{unchangedCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowUnchanged(!showUnchanged)}
className="h-6 text-xs"
>
{showUnchanged ? 'Hide' : 'Show'} {unchangedCount} unchanged
</Button>
)}
</div>
<div className="flex flex-col gap-1">
{differences.map((diff, idx) => {
if (diff.type === 'unchanged' && !showUnchanged) {
return null;
}
return (
<ArrayDiffItemDisplay key={idx} diff={diff} />
);
})}
</div>
</div>
);
}
function ArrayDiffItemDisplay({ diff }: { diff: ArrayDiffItem }) {
const isObject = typeof diff.newValue === 'object' || typeof diff.oldValue === 'object';
switch (diff.type) {
case 'added':
return (
<div className="flex items-start gap-2 p-2 rounded bg-green-500/10 border border-green-500/20">
<Plus className="h-4 w-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(diff.newValue)}
</span>
)}
</div>
</div>
);
case 'removed':
return (
<div className="flex items-start gap-2 p-2 rounded bg-red-500/10 border border-red-500/20">
<Minus className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
{isObject ? (
<ObjectDisplay value={diff.oldValue} className="line-through opacity-75" />
) : (
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(diff.oldValue)}
</span>
)}
</div>
</div>
);
case 'modified':
return (
<div className="flex flex-col gap-1 p-2 rounded bg-amber-500/10 border border-amber-500/20">
<div className="flex items-start gap-2">
<Edit className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm">
<div className="text-red-600 dark:text-red-400 line-through mb-1">
{isObject ? (
<ObjectDisplay value={diff.oldValue} />
) : (
formatFieldValue(diff.oldValue)
)}
</div>
<div className="text-green-600 dark:text-green-400">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
</div>
</div>
);
case 'unchanged':
return (
<div className="flex items-start gap-2 p-2 rounded bg-muted/20">
<Check className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="flex-1 text-sm text-muted-foreground">
{isObject ? (
<ObjectDisplay value={diff.newValue} />
) : (
formatFieldValue(diff.newValue)
)}
</div>
</div>
);
}
}
function ObjectDisplay({ value, className = '' }: { value: any; className?: string }) {
if (!value || typeof value !== 'object') {
return <span className={className}>{formatFieldValue(value)}</span>;
}
return (
<div className={`space-y-0.5 ${className}`}>
{Object.entries(value).map(([key, val]) => (
<div key={key} className="flex gap-2">
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
<span>{formatFieldValue(val)}</span>
</div>
))}
</div>
);
}
/**
* Compute differences between two arrays
*/
function computeArrayDiff(oldArray: any[], newArray: any[]): ArrayDiffItem[] {
const results: ArrayDiffItem[] = [];
const maxLength = Math.max(oldArray.length, newArray.length);
// Simple position-based comparison
for (let i = 0; i < maxLength; i++) {
const oldValue = i < oldArray.length ? oldArray[i] : undefined;
const newValue = i < newArray.length ? newArray[i] : undefined;
if (oldValue === undefined && newValue !== undefined) {
// Added
results.push({ type: 'added', newValue, index: i });
} else if (oldValue !== undefined && newValue === undefined) {
// Removed
results.push({ type: 'removed', oldValue, index: i });
} else if (!isEqual(oldValue, newValue)) {
// Modified
results.push({ type: 'modified', oldValue, newValue, index: i });
} else {
// Unchanged
results.push({ type: 'unchanged', oldValue, newValue, index: i });
}
}
return results;
}
/**
* Deep equality check
*/
function isEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => isEqual(item, b[i]));
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => isEqual(a[key], b[key]));
}
return false;
}

View File

@@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null); const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const [isPhotoOperation, setIsPhotoOperation] = useState(false);
useEffect(() => { useEffect(() => {
fetchSubmissionItems(); fetchSubmissionItems();
@@ -57,6 +58,15 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
setItemData(firstItem.item_data); setItemData(firstItem.item_data);
setOriginalData(firstItem.original_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 // Parse changed fields
const changed: string[] = []; const changed: string[] = [];
const data = firstItem.item_data as any; 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 // Build photos array for modal
const photos = []; const photos = [];
if (bannerImageUrl) { if (bannerImageUrl) {

View File

@@ -0,0 +1,191 @@
import { Badge } from '@/components/ui/badge';
import { formatFieldName, formatFieldValue } from '@/lib/submissionChangeDetection';
import type { FieldChange, ImageChange } from '@/lib/submissionChangeDetection';
import { ArrowRight } from 'lucide-react';
import { ArrayFieldDiff } from './ArrayFieldDiff';
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
interface FieldDiffProps {
change: FieldChange;
compact?: boolean;
}
export function FieldDiff({ change, compact = false }: FieldDiffProps) {
const { field, oldValue, newValue, changeType } = change;
// Check if this is an array field that needs special handling
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
return (
<ArrayFieldDiff
fieldName={formatFieldName(field)}
oldArray={oldValue}
newArray={newValue}
compact={compact}
/>
);
}
// Check if this is a special field type that needs custom rendering
const specialDisplay = SpecialFieldDisplay({ change, compact });
if (specialDisplay) {
return specialDisplay;
}
const getChangeColor = () => {
switch (changeType) {
case 'added': return 'text-green-600 dark:text-green-400';
case 'removed': return 'text-red-600 dark:text-red-400';
case 'modified': return 'text-amber-600 dark:text-amber-400';
default: return '';
}
};
if (compact) {
return (
<Badge variant="outline" className={getChangeColor()}>
{formatFieldName(field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">{formatFieldName(field)}</div>
{changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(newValue)}
</div>
)}
{changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</div>
)}
{changeType === 'modified' && (
<div className="flex items-center gap-2 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(newValue)}
</span>
</div>
)}
</div>
);
}
interface ImageDiffProps {
change: ImageChange;
compact?: boolean;
}
export function ImageDiff({ change, compact = false }: ImageDiffProps) {
const { type, oldUrl, newUrl } = change;
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
{type === 'banner' ? 'Banner' : 'Card'} Image
</Badge>
);
}
// Determine scenario
const isAddition = !oldUrl && newUrl;
const isRemoval = oldUrl && !newUrl;
const isReplacement = oldUrl && newUrl;
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
<div className="text-sm font-medium">
{type === 'banner' ? 'Banner' : 'Card'} Image
{isAddition && <span className="text-green-600 dark:text-green-400 ml-2">(New)</span>}
{isRemoval && <span className="text-red-600 dark:text-red-400 ml-2">(Removed)</span>}
{isReplacement && <span className="text-amber-600 dark:text-amber-400 ml-2">(Changed)</span>}
</div>
<div className="flex items-center gap-3">
{oldUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">Before</div>
<img
src={oldUrl}
alt="Previous"
className="w-full h-32 object-cover rounded border-2 border-red-500/50"
loading="lazy"
/>
</div>
)}
{oldUrl && newUrl && (
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
)}
{newUrl && (
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{isAddition ? 'New Image' : 'After'}</div>
<img
src={newUrl}
alt="New"
className="w-full h-32 object-cover rounded border-2 border-green-500/50"
loading="lazy"
/>
</div>
)}
</div>
</div>
);
}
interface LocationDiffProps {
oldLocation: any;
newLocation: any;
compact?: boolean;
}
export function LocationDiff({ oldLocation, newLocation, compact = false }: LocationDiffProps) {
const formatLocation = (loc: any) => {
if (!loc) return 'None';
if (typeof loc === 'string') return loc;
if (typeof loc === 'object') {
const parts = [loc.city, loc.state_province, loc.country].filter(Boolean);
return parts.join(', ') || 'Unknown';
}
return String(loc);
};
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Location
</Badge>
);
}
return (
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
<div className="text-sm font-medium">Location</div>
<div className="flex items-center gap-2 text-sm">
{oldLocation && (
<span className="text-red-600 dark:text-red-400 line-through">
{formatLocation(oldLocation)}
</span>
)}
{oldLocation && newLocation && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
{newLocation && (
<span className="text-green-600 dark:text-green-400">
{formatLocation(newLocation)}
</span>
)}
</div>
</div>
);
}

View File

@@ -5,14 +5,16 @@ import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react';
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService'; import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay'; import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
interface ItemReviewCardProps { interface ItemReviewCardProps {
item: SubmissionItemWithDeps; item: SubmissionItemWithDeps;
onEdit: () => void; onEdit: () => void;
onStatusChange: (status: 'approved' | 'rejected') => 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 isMobile = useIsMobile();
const getItemIcon = () => { const getItemIcon = () => {
@@ -39,74 +41,15 @@ export function ItemReviewCard({ item, onEdit, onStatusChange }: ItemReviewCardP
}; };
const renderItemPreview = () => { const renderItemPreview = () => {
const data = item.item_data; // Use detailed view for review manager with photo detection
switch (item.item_type) {
case 'park':
return ( return (
<div className="space-y-2"> <SubmissionChangesDisplay
<h4 className="font-semibold">{data.name}</h4> item={item}
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p> view="detailed"
<div className="flex gap-2 flex-wrap"> showImages={true}
{data.park_type && <Badge variant="outline">{data.park_type}</Badge>} submissionId={submissionId}
{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>
);
}
}; };
return ( return (

View File

@@ -14,9 +14,11 @@ import { useAuth } from '@/hooks/useAuth';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { PhotoModal } from './PhotoModal'; import { PhotoModal } from './PhotoModal';
import { SubmissionReviewManager } from './SubmissionReviewManager'; import { SubmissionReviewManager } from './SubmissionReviewManager';
import { useRealtimeSubmissions } from '@/hooks/useRealtimeSubmissions';
import { useIsMobile } from '@/hooks/use-mobile'; 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 { interface ModerationItem {
id: string; id: string;
@@ -54,6 +56,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [items, setItems] = useState<ModerationItem[]>([]); const [items, setItems] = useState<ModerationItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({}); const [notes, setNotes] = useState<Record<string, string>>({});
const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all'); const [activeEntityFilter, setActiveEntityFilter] = useState<EntityFilter>('all');
@@ -67,21 +70,28 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const { isAdmin, isSuperuser } = useUserRole(); const { isAdmin, isSuperuser } = useUserRole();
const { user } = useAuth(); const { user } = useAuth();
// Get admin settings for polling configuration
const { getAdminPanelRefreshMode, getAdminPanelPollInterval } = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
// Expose refresh method via ref // Expose refresh method via ref
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
refresh: () => { refresh: () => {
fetchItems(activeEntityFilter, activeStatusFilter); fetchItems(activeEntityFilter, activeStatusFilter, false); // Manual refresh shows loading
} }
}), [activeEntityFilter, activeStatusFilter]); }), [activeEntityFilter, activeStatusFilter]);
const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending') => { const fetchItems = async (entityFilter: EntityFilter = 'all', statusFilter: StatusFilter = 'pending', silent = false) => {
if (!user) { if (!user) {
console.log('Skipping fetch - user not authenticated');
return; return;
} }
try { try {
// Only show loading on initial load or filter change
if (!silent) {
setLoading(true); setLoading(true);
}
let reviewStatuses: string[] = []; let reviewStatuses: string[] = [];
let submissionStatuses: string[] = []; let submissionStatuses: string[] = [];
@@ -326,9 +336,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Sort by creation date (newest first for better UX) // 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()); 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); setItems(formattedItems);
} catch (error: any) { } catch (error: any) {
console.error('Error fetching moderation items:', error); console.error('Error fetching moderation items:', error);
@@ -343,46 +350,36 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
// Only clear loading if it was set
if (!silent) {
setLoading(false); setLoading(false);
} }
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
}; };
// Set up realtime subscriptions // Initial fetch on mount and filter changes
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,
});
useEffect(() => { useEffect(() => {
if (user) { if (user) {
fetchItems(activeEntityFilter, activeStatusFilter); fetchItems(activeEntityFilter, activeStatusFilter, false); // Show loading
} }
}, [activeEntityFilter, activeStatusFilter, user]); }, [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) => { const handleResetToPending = async (item: ModerationItem) => {
setActionLoading(item.id); setActionLoading(item.id);
try { try {
@@ -467,7 +464,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
) => { ) => {
// Prevent multiple clicks on the same item // Prevent multiple clicks on the same item
if (actionLoading === item.id) { if (actionLoading === item.id) {
console.log('Action already in progress for item:', item.id);
return; return;
} }
@@ -527,8 +523,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
manufacturer_id: modelManufacturerId, manufacturer_id: modelManufacturerId,
category: item.content.new_ride_model.category, category: item.content.new_ride_model.category,
ride_type: item.content.new_ride_model.ride_type, ride_type: item.content.new_ride_model.ride_type,
description: item.content.new_ride_model.description, description: item.content.new_ride_model.description
technical_specs: item.content.new_ride_model.technical_specs
}) })
.select() .select()
.single(); .single();
@@ -584,8 +579,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Handle photo submissions - create photos records when approved // Handle photo submissions - create photos records when approved
if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') { if (action === 'approved' && item.type === 'content_submission' && item.submission_type === 'photo') {
console.log('🖼️ [PHOTO APPROVAL] Starting photo submission approval');
try { try {
// Fetch photo submission from new relational tables // Fetch photo submission from new relational tables
const { data: photoSubmission, error: fetchError } = await supabase const { data: photoSubmission, error: fetchError } = await supabase
@@ -598,15 +591,13 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.eq('submission_id', item.id) .eq('submission_id', item.id)
.single(); .single();
console.log('🖼️ [PHOTO APPROVAL] Fetched photo submission:', photoSubmission);
if (fetchError || !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'); throw new Error('Failed to fetch photo submission data');
} }
if (!photoSubmission.items || photoSubmission.items.length === 0) { 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'); throw new Error('No photos found in submission');
} }
@@ -616,10 +607,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.select('id') .select('id')
.eq('submission_id', item.id); .eq('submission_id', item.id);
console.log('🖼️ [PHOTO APPROVAL] Existing photos check:', existingPhotos);
if (existingPhotos && existingPhotos.length > 0) { if (existingPhotos && existingPhotos.length > 0) {
console.log('🖼️ [PHOTO APPROVAL] Photos already exist for this submission, skipping creation');
// Just update submission status // Just update submission status
const { error: updateError } = await supabase const { error: updateError } = await supabase
@@ -649,19 +637,15 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
approved_at: new Date().toISOString(), approved_at: new Date().toISOString(),
})); }));
console.log('🖼️ [PHOTO APPROVAL] Creating photo records:', photoRecords);
const { data: createdPhotos, error: insertError } = await supabase const { data: createdPhotos, error: insertError } = await supabase
.from('photos') .from('photos')
.insert(photoRecords) .insert(photoRecords)
.select(); .select();
if (insertError) { if (insertError) {
console.error('🖼️ [PHOTO APPROVAL] ERROR: Failed to insert photos:', insertError); console.error('Failed to insert photos:', insertError);
throw insertError; throw insertError;
} }
console.log('🖼️ [PHOTO APPROVAL] ✅ Successfully created photos:', createdPhotos);
} }
// Update submission status // Update submission status
@@ -676,12 +660,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.eq('id', item.id); .eq('id', item.id);
if (updateError) { if (updateError) {
console.error('🖼️ [PHOTO APPROVAL] Error updating submission:', updateError); console.error('Error updating submission:', updateError);
throw updateError; throw updateError;
} }
console.log('🖼️ [PHOTO APPROVAL] ✅ Complete! Photos approved and published');
toast({ toast({
title: "Photos Approved", title: "Photos Approved",
description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`, description: `Successfully approved and published ${photoSubmission.items.length} photo(s)`,
@@ -692,8 +674,7 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return; return;
} catch (error: any) { } catch (error: any) {
console.error('🖼️ [PHOTO APPROVAL] ❌ FATAL ERROR:', error); console.error('Photo approval error:', error);
console.error('🖼️ [PHOTO APPROVAL] Error details:', error.message, error.code, error.details);
throw error; throw error;
} }
} }
@@ -707,8 +688,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
.in('status', ['pending', 'rejected']); .in('status', ['pending', 'rejected']);
if (!itemsError && submissionItems && submissionItems.length > 0) { if (!itemsError && submissionItems && submissionItems.length > 0) {
console.log(`Found ${submissionItems.length} pending submission items for ${item.id}`);
if (action === 'approved') { if (action === 'approved') {
// Call the edge function to process all items // Call the edge function to process all items
const { data: approvalData, error: approvalError } = await supabase.functions.invoke( 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}`); throw new Error(`Failed to process submission items: ${approvalError.message}`);
} }
console.log('Submission items processed successfully:', approvalData);
toast({ toast({
title: "Submission Approved", title: "Submission Approved",
description: `Successfully processed ${submissionItems.length} item(s)`, description: `Successfully processed ${submissionItems.length} item(s)`,
@@ -738,7 +715,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return; return;
} else if (action === 'rejected') { } else if (action === 'rejected') {
// Cascade rejection to all pending items // Cascade rejection to all pending items
console.log('Cascading rejection to submission items');
const { error: rejectError } = await supabase const { error: rejectError } = await supabase
.from('submission_items') .from('submission_items')
.update({ .update({
@@ -752,8 +728,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (rejectError) { if (rejectError) {
console.error('Failed to cascade rejection:', rejectError); console.error('Failed to cascade rejection:', rejectError);
// Don't fail the whole operation, just log it // 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; updateData.reviewer_notes = moderatorNotes;
} }
console.log('Updating item:', item.id, 'with data:', updateData, 'table:', table);
const { error, data } = await supabase const { error, data } = await supabase
.from(table) .from(table)
.update(updateData) .update(updateData)
@@ -794,16 +766,12 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
throw error; throw error;
} }
console.log('Update response:', { data, rowsAffected: data?.length });
// Check if the update actually affected any rows // Check if the update actually affected any rows
if (!data || data.length === 0) { if (!data || data.length === 0) {
console.error('No rows were updated. This might be due to RLS policies or the item not existing.'); 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.'); 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({ toast({
title: `Content ${action}`, title: `Content ${action}`,
description: `The ${item.type} has been ${action}`, description: `The ${item.type} has been ${action}`,
@@ -823,10 +791,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
return newNotes; 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')) || if ((activeStatusFilter === 'pending' && (action === 'approved' || action === 'rejected')) ||
(activeStatusFilter === 'flagged' && (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) { } catch (error: any) {
@@ -854,7 +822,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
// Prevent duplicate calls // Prevent duplicate calls
if (actionLoading === item.id) { if (actionLoading === item.id) {
console.log('Deletion already in progress for:', item.id);
return; return;
} }
@@ -864,8 +831,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
setItems(prev => prev.filter(i => i.id !== item.id)); setItems(prev => prev.filter(i => i.id !== item.id));
try { try {
console.log('Starting deletion process for submission:', item.id);
// Step 1: Extract photo IDs from the submission content // Step 1: Extract photo IDs from the submission content
const photoIds: string[] = []; const photoIds: string[] = [];
const validImageIds: string[] = []; const validImageIds: string[] = [];
@@ -875,18 +840,12 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
const photosArray = item.content?.content?.photos || item.content?.photos; const photosArray = item.content?.content?.photos || item.content?.photos;
if (photosArray && Array.isArray(photosArray)) { if (photosArray && Array.isArray(photosArray)) {
console.log('Processing photos from content:', photosArray);
for (const photo of 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 = ''; let imageId = '';
// First try to use the stored imageId directly // First try to use the stored imageId directly
if (photo.imageId) { if (photo.imageId) {
imageId = photo.imageId; imageId = photo.imageId;
console.log('Using stored image ID:', imageId);
} else if (photo.url) { } else if (photo.url) {
// Check if this looks like a Cloudflare image ID (not a blob URL) // Check if this looks like a Cloudflare image ID (not a blob URL)
if (photo.url.startsWith('blob:')) { if (photo.url.startsWith('blob:')) {
@@ -908,7 +867,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
imageId = cloudflareMatch[1]; imageId = cloudflareMatch[1];
} }
} }
console.log('Extracted image ID from URL:', imageId, 'from URL:', photo.url);
} }
if (imageId) { 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) // Step 2: Delete photos from Cloudflare Images (if any valid IDs)
if (validImageIds.length > 0) { if (validImageIds.length > 0) {
const deletePromises = validImageIds.map(async (imageId) => { const deletePromises = validImageIds.map(async (imageId) => {
try { try {
console.log('Attempting to delete image from Cloudflare:', imageId);
// Use Supabase SDK - automatically includes session token // Use Supabase SDK - automatically includes session token
const { data, error } = await supabase.functions.invoke('upload-image', { const { data, error } = await supabase.functions.invoke('upload-image', {
method: 'DELETE', method: 'DELETE',
@@ -939,8 +893,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
throw new Error(`Failed to delete image: ${error.message}`); throw new Error(`Failed to delete image: ${error.message}`);
} }
console.log('Successfully deleted image:', imageId, data);
} catch (deleteError) { } catch (deleteError) {
console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError); console.error(`Failed to delete photo ${imageId} from Cloudflare:`, deleteError);
// Continue with other deletions - don't fail the entire operation // 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 // Step 3: Delete the submission from the database
console.log('Deleting submission from database:', item.id);
const { error } = await supabase const { error } = await supabase
.from('content_submissions') .from('content_submissions')
.delete() .delete()
@@ -973,8 +924,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
if (checkData && !checkError) { if (checkData && !checkError) {
console.error('DELETION FAILED: Item still exists in database after delete operation'); console.error('DELETION FAILED: Item still exists in database after delete operation');
throw new Error('Deletion failed - item still exists in database'); throw new Error('Deletion failed - item still exists in database');
} else {
console.log('Verified: Submission successfully deleted from database');
} }
const deletedCount = validImageIds.length; const deletedCount = validImageIds.length;
@@ -1201,7 +1150,6 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
console.error('Failed to load review photo:', photo.url); console.error('Failed to load review photo:', photo.url);
(e.target as HTMLImageElement).style.display = 'none'; (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"> <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" /> <Eye className="w-4 h-4" />
@@ -1260,15 +1208,23 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
target.style.display = 'none'; target.style.display = 'none';
const parent = target.parentElement; const parent = target.parentElement;
if (parent) { if (parent) {
parent.innerHTML = ` // Create elements safely using DOM API to prevent XSS
<div class="absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs"> const errorContainer = document.createElement('div');
<div>⚠️ Image failed to load</div> errorContainer.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs';
<div class="mt-1 font-mono text-xs break-all px-2">${photo.url}</div>
</div> 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);
} }
}} }}
onLoad={() => console.log('Photo submission loaded:', photo.url)}
/> />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 hover:opacity-100 transition-opacity rounded"> <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" /> <Eye className="w-5 h-5" />
@@ -1468,13 +1424,17 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
{item.content.ride?.max_speed_kmh && ( {item.content.ride?.max_speed_kmh && (
<div> <div>
<span className="font-medium">Max Speed: </span> <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> </div>
)} )}
{item.content.ride?.max_height_meters && ( {item.content.ride?.max_height_meters && (
<div> <div>
<span className="font-medium">Max Height: </span> <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>
)} )}
</div> </div>
@@ -1487,10 +1447,10 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
item.submission_type === 'property_owner' || item.submission_type === 'property_owner' ||
item.submission_type === 'park' || item.submission_type === 'park' ||
item.submission_type === 'ride') ? ( item.submission_type === 'ride') ? (
<EntityEditPreview <SubmissionItemsList
submissionId={item.id} submissionId={item.id}
entityType={item.submission_type} view="summary"
entityName={item.content.name || item.entity_name} showImages={true}
/> />
) : ( ) : (
<div> <div>
@@ -1736,6 +1696,9 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
<div className="space-y-4"> <div className="space-y-4">
{/* Filter Bar */} {/* 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 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={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}> <div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
<Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label> <Label className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>Entity Type</Label>

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react'; import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -8,6 +8,8 @@ import { Label } from '@/components/ui/label';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAdminSettings } from '@/hooks/useAdminSettings';
import { useAuth } from '@/hooks/useAuth';
interface Report { interface Report {
id: string; id: string;
@@ -38,14 +40,35 @@ const STATUS_COLORS = {
dismissed: 'outline', dismissed: 'outline',
} as const; } as const;
export function ReportsQueue() { export interface ReportsQueueRef {
refresh: () => void;
}
export const ReportsQueue = forwardRef<ReportsQueueRef>((props, ref) => {
const [reports, setReports] = useState<Report[]>([]); const [reports, setReports] = useState<Report[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const { toast } = useToast(); 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 { try {
// Only show loading on initial load
if (!silent) {
setLoading(true);
}
const { data, error } = await supabase const { data, error } = await supabase
.from('reports') .from('reports')
.select(` .select(`
@@ -106,13 +129,35 @@ export function ReportsQueue() {
variant: "destructive", variant: "destructive",
}); });
} finally { } finally {
// Only clear loading if it was set
if (!silent) {
setLoading(false); setLoading(false);
} }
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
}; };
// Initial fetch on mount
useEffect(() => { 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') => { const handleReportAction = async (reportId: string, action: 'reviewed' | 'dismissed') => {
setActionLoading(reportId); setActionLoading(reportId);
@@ -258,4 +303,4 @@ export function ReportsQueue() {
))} ))}
</div> </div>
); );
} });

View File

@@ -0,0 +1,327 @@
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { SpeedDisplay } from '@/components/ui/speed-display';
import { MapPin, ArrowRight, Calendar, ExternalLink } from 'lucide-react';
import type { FieldChange } from '@/lib/submissionChangeDetection';
import { formatFieldValue } from '@/lib/submissionChangeDetection';
interface SpecialFieldDisplayProps {
change: FieldChange;
compact?: boolean;
}
export function SpecialFieldDisplay({ change, compact = false }: SpecialFieldDisplayProps) {
const fieldName = change.field.toLowerCase();
// Detect field type
if (fieldName.includes('speed') || fieldName === 'max_speed_kmh') {
return <SpeedFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('height') || fieldName.includes('length') ||
fieldName === 'max_height_meters' || fieldName === 'length_meters' ||
fieldName === 'drop_height_meters') {
return <MeasurementFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'status') {
return <StatusFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('date') && !fieldName.includes('updated') && !fieldName.includes('created')) {
return <DateFieldDisplay change={change} compact={compact} />;
}
if (fieldName.includes('_id') && fieldName !== 'id' && fieldName !== 'user_id') {
return <RelationshipFieldDisplay change={change} compact={compact} />;
}
if (fieldName === 'latitude' || fieldName === 'longitude') {
return <CoordinateFieldDisplay change={change} compact={compact} />;
}
// Fallback to null, will be handled by regular FieldDiff
return null;
}
function SpeedFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Speed
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<SpeedDisplay kmh={change.newValue} />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <SpeedDisplay kmh={change.newValue} />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<SpeedDisplay kmh={change.oldValue} />
</div>
)}
</div>
);
}
function MeasurementFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-purple-600 dark:text-purple-400">
Measurement
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<div className="text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<div className="text-green-600 dark:text-green-400">
<MeasurementDisplay value={change.newValue} type="distance" />
</div>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ <MeasurementDisplay value={change.newValue} type="distance" />
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
<MeasurementDisplay value={change.oldValue} type="distance" />
</div>
)}
</div>
);
}
function StatusFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
const getStatusColor = (status: string) => {
const statusLower = String(status).toLowerCase();
if (statusLower === 'operating' || statusLower === 'active') return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20';
if (statusLower === 'closed' || statusLower === 'inactive') return 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20';
if (statusLower === 'under_construction' || statusLower === 'pending') return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20';
return 'bg-muted/30 text-muted-foreground';
};
if (compact) {
return (
<Badge variant="outline" className="text-indigo-600 dark:text-indigo-400">
Status
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3">
<Badge className={`${getStatusColor(change.oldValue)} line-through`}>
{formatFieldValue(change.oldValue)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
</div>
)}
{change.changeType === 'added' && (
<Badge className={getStatusColor(change.newValue)}>
{formatFieldValue(change.newValue)}
</Badge>
)}
{change.changeType === 'removed' && (
<Badge className={`${getStatusColor(change.oldValue)} line-through opacity-75`}>
{formatFieldValue(change.oldValue)}
</Badge>
)}
</div>
);
}
function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-teal-600 dark:text-teal-400">
<Calendar className="h-3 w-3 mr-1" />
Date
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400">
{formatFieldValue(change.newValue)}
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400">
+ {formatFieldValue(change.newValue)}
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through">
{formatFieldValue(change.oldValue)}
</div>
)}
</div>
);
}
function RelationshipFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
// This would ideally fetch entity names, but for now we show IDs with better formatting
const formatFieldName = (name: string) =>
name.replace(/_id$/, '').replace(/_/g, ' ').trim()
.replace(/^./, str => str.toUpperCase());
if (compact) {
return (
<Badge variant="outline" className="text-cyan-600 dark:text-cyan-400">
{formatFieldName(change.field)}
</Badge>
);
}
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm font-mono">
<span className="text-red-600 dark:text-red-400 line-through text-xs">
{String(change.oldValue).slice(0, 8)}...
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 text-xs">
{String(change.newValue).slice(0, 8)}...
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono text-xs">
+ {String(change.newValue).slice(0, 8)}...
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono text-xs">
{String(change.oldValue).slice(0, 8)}...
</div>
)}
</div>
);
}
function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
if (compact) {
return (
<Badge variant="outline" className="text-orange-600 dark:text-orange-400">
<MapPin className="h-3 w-3 mr-1" />
Coordinates
</Badge>
);
}
const formatFieldName = (name: string) =>
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
.replace(/^./, str => str.toUpperCase());
return (
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
<div className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4" />
{formatFieldName(change.field)}
</div>
{change.changeType === 'modified' && (
<div className="flex items-center gap-3 text-sm">
<span className="text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="text-green-600 dark:text-green-400 font-mono">
{Number(change.newValue).toFixed(6)}°
</span>
</div>
)}
{change.changeType === 'added' && (
<div className="text-sm text-green-600 dark:text-green-400 font-mono">
+ {Number(change.newValue).toFixed(6)}°
</div>
)}
{change.changeType === 'removed' && (
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono">
{Number(change.oldValue).toFixed(6)}°
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison';
import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './PhotoComparison';
import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection';
import type { SubmissionItemData } from '@/types/submissions';
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react';
interface SubmissionChangesDisplayProps {
item: SubmissionItemData | SubmissionItemWithDeps;
view?: 'summary' | 'detailed';
showImages?: boolean;
submissionId?: string;
}
// Helper to determine change magnitude
function getChangeMagnitude(totalChanges: number, hasImages: boolean, action: string) {
if (action === 'delete') return { label: 'Deletion', variant: 'destructive' as const, icon: AlertTriangle };
if (action === 'create') return { label: 'New', variant: 'default' as const, icon: Plus };
if (hasImages) return { label: 'Major', variant: 'default' as const, icon: Edit };
if (totalChanges >= 5) return { label: 'Major', variant: 'default' as const, icon: Edit };
if (totalChanges >= 3) return { label: 'Moderate', variant: 'secondary' as const, icon: Edit };
return { label: 'Minor', variant: 'outline' as const, icon: Edit };
}
export function SubmissionChangesDisplay({
item,
view = 'summary',
showImages = true,
submissionId
}: SubmissionChangesDisplayProps) {
const [changes, setChanges] = useState<ChangesSummary | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadChanges = async () => {
setLoading(true);
const detectedChanges = await detectChanges(item, submissionId);
setChanges(detectedChanges);
setLoading(false);
};
loadChanges();
}, [item, submissionId]);
if (loading || !changes) {
return <Skeleton className="h-16 w-full" />;
}
// Get appropriate icon for entity type
const getEntityIcon = () => {
const iconClass = "h-4 w-4";
switch (item.item_type) {
case 'park': return <Building2 className={iconClass} />;
case 'ride': return <Train className={iconClass} />;
case 'manufacturer':
case 'operator':
case 'property_owner':
case 'designer': return <Building className={iconClass} />;
case 'photo': return <ImageIcon className={iconClass} />;
default: return <MapPin className={iconClass} />;
}
};
// Get action badge
const getActionBadge = () => {
switch (changes.action) {
case 'create':
return <Badge className="bg-green-600"><Plus className="h-3 w-3 mr-1" />New</Badge>;
case 'edit':
return <Badge className="bg-amber-600"><Edit className="h-3 w-3 mr-1" />Edit</Badge>;
case 'delete':
return <Badge variant="destructive"><Trash2 className="h-3 w-3 mr-1" />Delete</Badge>;
}
};
const magnitude = getChangeMagnitude(
changes.totalChanges,
changes.imageChanges.length > 0,
changes.action
);
if (view === 'summary') {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
{getEntityIcon()}
<span className="font-medium">{changes.entityName}</span>
{getActionBadge()}
{changes.action === 'edit' && (
<Badge variant={magnitude.variant} className="text-xs">
{magnitude.label} Change
</Badge>
)}
</div>
{changes.action === 'edit' && changes.totalChanges > 0 && (
<div className="flex flex-wrap gap-1">
{changes.fieldChanges.slice(0, 5).map((change, idx) => (
<FieldDiff key={idx} change={change} compact />
))}
{changes.imageChanges.map((change, idx) => (
<ImageDiff key={`img-${idx}`} change={change} compact />
))}
{changes.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={`photo-${idx}`} photos={change.photos} compact />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={`photo-${idx}`} photo={change.photo} compact />;
}
return null;
})}
{changes.hasLocationChange && (
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
Location
</Badge>
)}
{changes.totalChanges > 5 && (
<Badge variant="outline">
+{changes.totalChanges - 5} more
</Badge>
)}
</div>
)}
{changes.action === 'create' && item.item_data?.description && (
<div className="text-sm text-muted-foreground line-clamp-2">
{item.item_data.description}
</div>
)}
{changes.action === 'delete' && (
<div className="text-sm text-destructive">
Marked for deletion
</div>
)}
</div>
);
}
// Detailed view
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{getEntityIcon()}
<h3 className="text-lg font-semibold">{changes.entityName}</h3>
{getActionBadge()}
</div>
{changes.action === 'create' && (
<div className="text-sm text-muted-foreground">
Creating new {item.item_type}
</div>
)}
{changes.action === 'delete' && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
This {item.item_type} will be deleted
</div>
)}
{changes.action === 'edit' && changes.totalChanges > 0 && (
<>
{changes.fieldChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Field Changes ({changes.fieldChanges.length})</h4>
<div className="grid gap-2">
{changes.fieldChanges.map((change, idx) => (
<FieldDiff key={idx} change={change} />
))}
</div>
</div>
)}
{showImages && changes.imageChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Image Changes</h4>
<div className="grid gap-2">
{changes.imageChanges.map((change, idx) => (
<ImageDiff key={idx} change={change} />
))}
</div>
</div>
)}
{showImages && changes.photoChanges.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Photo Changes</h4>
<div className="grid gap-2">
{changes.photoChanges.map((change, idx) => {
if (change.type === 'added' && change.photos) {
return <PhotoAdditionPreview key={idx} photos={change.photos} compact={false} />;
}
if (change.type === 'edited' && change.photo) {
return <PhotoEditPreview key={idx} photo={change.photo} compact={false} />;
}
if (change.type === 'deleted' && change.photo) {
return <PhotoDeletionPreview key={idx} photo={change.photo} compact={false} />;
}
return null;
})}
</div>
</div>
)}
{changes.hasLocationChange && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">Location Change</h4>
<LocationDiff
oldLocation={item.original_data?.location || item.original_data?.location_id}
newLocation={item.item_data?.location || item.item_data?.location_id}
/>
</div>
)}
</>
)}
{changes.action === 'edit' && changes.totalChanges === 0 && (
<div className="text-sm text-muted-foreground">
No changes detected
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
import { Skeleton } from '@/components/ui/skeleton';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { AlertCircle } from 'lucide-react';
import type { SubmissionItemData } from '@/types/submissions';
interface SubmissionItemsListProps {
submissionId: string;
view?: 'summary' | 'detailed';
showImages?: boolean;
}
export function SubmissionItemsList({
submissionId,
view = 'summary',
showImages = true
}: SubmissionItemsListProps) {
const [items, setItems] = useState<SubmissionItemData[]>([]);
const [hasPhotos, setHasPhotos] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchSubmissionItems();
}, [submissionId]);
const fetchSubmissionItems = async () => {
try {
setLoading(true);
setError(null);
// Fetch submission items
const { data: itemsData, error: itemsError } = await supabase
.from('submission_items')
.select('*')
.eq('submission_id', submissionId)
.order('order_index');
if (itemsError) throw itemsError;
// Check for photo submissions (using array query to avoid 406)
const { data: photoData, error: photoError } = await supabase
.from('photo_submissions')
.select('id')
.eq('submission_id', submissionId);
if (photoError) {
console.warn('Error checking photo submissions:', photoError);
}
setItems((itemsData || []) as SubmissionItemData[]);
setHasPhotos(photoData && photoData.length > 0);
} catch (err) {
console.error('Error fetching submission items:', err);
setError('Failed to load submission details');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-16 w-full" />
{view === 'detailed' && <Skeleton className="h-32 w-full" />}
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
);
}
if (items.length === 0 && !hasPhotos) {
return (
<div className="text-sm text-muted-foreground">
No items found for this submission
</div>
);
}
return (
<div className="flex flex-col gap-3">
{/* Show regular submission items */}
{items.map((item) => (
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<SubmissionChangesDisplay
item={item}
view={view}
showImages={showImages}
submissionId={submissionId}
/>
</div>
))}
{/* Show photo submission if exists */}
{hasPhotos && (
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
<PhotoSubmissionDisplay submissionId={submissionId} />
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useRealtimeSubmissionItems } from '@/hooks/useRealtimeSubmissionItems';
import { import {
fetchSubmissionItems, fetchSubmissionItems,
buildDependencyTree, buildDependencyTree,
@@ -60,20 +59,6 @@ export function SubmissionReviewManager({
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const Container = isMobile ? Sheet : Dialog; 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(() => { useEffect(() => {
if (open && submissionId) { if (open && submissionId) {
loadSubmissionItems(); loadSubmissionItems();
@@ -473,7 +458,11 @@ export function SubmissionReviewManager({
<ItemReviewCard <ItemReviewCard
item={item} item={item}
onEdit={() => handleEdit(item)} onEdit={() => handleEdit(item)}
onStatusChange={(status) => handleItemStatusChange(item.id, status)} onStatusChange={async () => {
// Status changes handled via approve/reject actions
await loadSubmissionItems();
}}
submissionId={submissionId}
/> />
</div> </div>
))} ))}

View File

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

View File

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

View File

@@ -59,7 +59,7 @@ export function ParkCard({ park }: ParkCardProps) {
)} )}
{/* Gradient Overlay */} {/* 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 */} {/* Status Badge */}
<Badge className={`absolute top-3 right-3 ${getStatusColor(park.status)} border`}> <Badge className={`absolute top-3 right-3 ${getStatusColor(park.status)} border`}>

View File

@@ -19,7 +19,14 @@ const reviewSchema = z.object({
title: z.string().optional(), title: z.string().optional(),
content: z.string().min(10, 'Review must be at least 10 characters long'), content: z.string().min(10, 'Review must be at least 10 characters long'),
visit_date: z.string().optional(), 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() photos: z.array(z.string()).optional()
}); });
type ReviewFormData = z.infer<typeof reviewSchema>; type ReviewFormData = z.infer<typeof reviewSchema>;
@@ -189,7 +196,8 @@ export function ReviewForm({
{entityType === 'ride' && <div className="space-y-2"> {entityType === 'ride' && <div className="space-y-2">
<Label htmlFor="wait_time">Wait Time (minutes)</Label> <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', { <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>} </div>}

View File

@@ -49,7 +49,7 @@ export function ReviewsList({ entityType, entityId, entityName }: ReviewsListPro
.from('reviews') .from('reviews')
.select(` .select(`
*, *,
profiles:user_id(username, avatar_url, display_name) profiles!reviews_user_id_fkey(username, avatar_url, display_name)
`) `)
.eq('moderation_status', 'approved') .eq('moderation_status', 'approved')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false });

View File

@@ -1,16 +1,44 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RideCoasterStat } from "@/types/database"; import { RideCoasterStat } from "@/types/database";
import { TrendingUp } from "lucide-react"; import { TrendingUp } from "lucide-react";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { convertValueFromMetric, detectUnitType, getMetricUnit, getDisplayUnit } from "@/lib/units";
interface CoasterStatisticsProps { interface CoasterStatisticsProps {
statistics?: RideCoasterStat[]; statistics?: RideCoasterStat[];
} }
export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => { export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => {
const { preferences } = useUnitPreferences();
if (!statistics || statistics.length === 0) { if (!statistics || statistics.length === 0) {
return null; 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 // Group stats by category
const groupedStats = statistics.reduce((acc, stat) => { const groupedStats = statistics.reduce((acc, stat) => {
const category = stat.category || 'General'; const category = stat.category || 'General';
@@ -53,8 +81,7 @@ export const CoasterStatistics = ({ statistics }: CoasterStatisticsProps) => {
)} )}
</div> </div>
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
{stat.stat_value.toLocaleString()} {getDisplayValue(stat)}
{stat.unit && ` ${stat.unit}`}
</span> </span>
</div> </div>
))} ))}

View File

@@ -34,10 +34,10 @@ export function RideCard({ ride, showParkName = true, className, parkSlug }: Rid
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'operating': return 'bg-green-500/20 text-green-400 border-green-500/30'; case 'operating': return 'bg-green-500/50 text-green-200 border-green-500/50';
case 'seasonal': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'; case 'seasonal': return 'bg-yellow-500/50 text-yellow-200 border-yellow-500/50';
case 'under_construction': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; case 'under_construction': return 'bg-blue-500/50 text-blue-200 border-blue-500/50';
default: return 'bg-red-500/20 text-red-400 border-red-500/30'; default: return 'bg-red-500/50 text-red-200 border-red-500/50';
} }
}; };

View File

@@ -1,11 +1,12 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Zap, TrendingUp, Award, Sparkles } from 'lucide-react'; import { Zap, TrendingUp, Award, Sparkles } from 'lucide-react';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
interface RideHighlight { interface RideHighlight {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: React.ReactNode;
} }
interface RideHighlightsProps { interface RideHighlightsProps {
@@ -20,7 +21,7 @@ export function RideHighlights({ ride }: RideHighlightsProps) {
highlights.push({ highlights.push({
icon: <Zap className="w-5 h-5 text-amber-500" />, icon: <Zap className="w-5 h-5 text-amber-500" />,
label: 'High Speed', 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({ highlights.push({
icon: <TrendingUp className="w-5 h-5 text-blue-500" />, icon: <TrendingUp className="w-5 h-5 text-blue-500" />,
label: 'Tall Structure', label: 'Tall Structure',
value: `${ride.max_height_meters}m high` value: <MeasurementDisplay value={ride.max_height_meters} type="height" className="inline" />
}); });
} }

View File

@@ -0,0 +1,84 @@
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { FerrisWheel } from 'lucide-react';
import { RideModel } from '@/types/database';
import { useNavigate } from 'react-router-dom';
interface RideModelCardProps {
model: RideModel;
manufacturerSlug: string;
}
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
const navigate = useNavigate();
const formatCategory = (category: string) => {
return category.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
};
const formatRideType = (type: string) => {
return type.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
};
const rideCount = (model as any).rides?.[0]?.count || 0;
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group">
<div
className="aspect-video bg-gradient-to-br from-primary/10 via-secondary/10 to-accent/10 relative overflow-hidden"
>
{((model as any).card_image_url || (model as any).card_image_id) ? (
<img
src={(model as any).card_image_url || `https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/${(model as any).card_image_id}/public`}
alt={model.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<div className="flex items-center justify-center h-full">
<FerrisWheel className="w-16 h-16 text-muted-foreground/30" />
</div>
)}
</div>
<CardContent className="p-4 space-y-3">
<div>
<h3 className="font-semibold text-lg line-clamp-1 group-hover:text-primary transition-colors">
{model.name}
</h3>
{model.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{model.description}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="text-xs">
{formatCategory(model.category)}
</Badge>
<Badge variant="outline" className="text-xs">
{formatRideType(model.ride_type)}
</Badge>
</div>
<div className="pt-2 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{rideCount} {rideCount === 1 ? 'ride' : 'rides'}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/rides?model=${model.slug}`)}
>
View Rides
</Button>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

@@ -1,22 +0,0 @@
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
interface RidePhotoGalleryProps {
rideId: string;
rideName: string;
parkId?: string;
}
/**
* Backwards-compatible wrapper for RidePhotoGallery
* Uses the generic EntityPhotoGallery component internally
*/
export function RidePhotoGallery({ rideId, rideName, parkId }: RidePhotoGalleryProps) {
return (
<EntityPhotoGallery
entityId={rideId}
entityType="ride"
entityName={rideName}
parentId={parkId}
/>
);
}

View File

@@ -1,16 +1,51 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { RideTechnicalSpec } from "@/types/database"; import { RideTechnicalSpec } from "@/types/database";
import { Wrench } from "lucide-react"; import { Wrench } from "lucide-react";
import { useUnitPreferences } from "@/hooks/useUnitPreferences";
import { convertValueFromMetric, detectUnitType, getMetricUnit, getDisplayUnit } from "@/lib/units";
interface TechnicalSpecificationsProps { interface TechnicalSpecificationsProps {
specifications?: RideTechnicalSpec[]; specifications?: RideTechnicalSpec[];
} }
export const TechnicalSpecifications = ({ specifications }: TechnicalSpecificationsProps) => { export const TechnicalSpecifications = ({ specifications }: TechnicalSpecificationsProps) => {
const { preferences } = useUnitPreferences();
if (!specifications || specifications.length === 0) { if (!specifications || specifications.length === 0) {
return null; 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 // Group specs by category
const groupedSpecs = specifications.reduce((acc, spec) => { const groupedSpecs = specifications.reduce((acc, spec) => {
const category = spec.category || 'General'; 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 font-medium">{spec.spec_name}</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{spec.spec_value} {getDisplayValue(spec)}
{spec.unit && ` ${spec.unit}`}
</span> </span>
</div> </div>
))} ))}

View File

@@ -123,10 +123,37 @@ export function AutocompleteSearch({
navigate(`/parks/${searchResult.slug || searchResult.id}`); navigate(`/parks/${searchResult.slug || searchResult.id}`);
break; break;
case 'ride': case 'ride':
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}`); navigate(`/rides/${searchResult.id}`);
}
break; break;
case 'company': case 'company':
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}`); navigate(`/companies/${searchResult.id}`);
}
} else {
navigate(`/companies/${searchResult.id}`);
}
break; break;
} }
} }

View File

@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Star, MapPin, Zap, Factory, Clock, Users, Calendar, Ruler, Gauge, Building } from 'lucide-react'; import { Star, MapPin, Zap, Factory, Clock, Users, Calendar, Ruler, Gauge, Building } from 'lucide-react';
import { SearchResult } from '@/hooks/useSearch'; import { SearchResult } from '@/hooks/useSearch';
import { MeasurementDisplay } from '@/components/ui/measurement-display';
interface EnhancedSearchResultsProps { interface EnhancedSearchResultsProps {
results: SearchResult[]; results: SearchResult[];
@@ -94,13 +95,13 @@ export function EnhancedSearchResults({ results, loading, hasMore, onLoadMore }:
{rideData?.max_height_meters && ( {rideData?.max_height_meters && (
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-muted-foreground">
<Ruler className="w-3 h-3" /> <Ruler className="w-3 h-3" />
<span>{rideData.max_height_meters}m</span> <MeasurementDisplay value={rideData.max_height_meters} type="height" className="inline" />
</div> </div>
)} )}
{rideData?.max_speed_kmh && ( {rideData?.max_speed_kmh && (
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <div className="flex items-center gap-1 text-xs text-muted-foreground">
<Gauge className="w-3 h-3" /> <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> </div>
)} )}
{rideData?.intensity_level && ( {rideData?.intensity_level && (

View File

@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { useToast } from '@/hooks/use-toast';
import { Monitor, Smartphone, Tablet, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
interface UserSession {
id: string;
device_info: any;
last_activity: string;
created_at: string;
expires_at: string;
session_token: string;
}
export function SessionsTab() {
const { user } = useAuth();
const { toast } = useToast();
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
const fetchSessions = async () => {
if (!user) return;
const { data, error } = await supabase
.from('user_sessions')
.select('*')
.eq('user_id', user.id)
.order('last_activity', { ascending: false });
if (error) {
console.error('Error fetching sessions:', error);
} else {
setSessions(data || []);
}
setLoading(false);
};
useEffect(() => {
fetchSessions();
}, [user]);
const revokeSession = async (sessionId: string) => {
const { error } = await supabase
.from('user_sessions')
.delete()
.eq('id', sessionId);
if (error) {
toast({
title: 'Error',
description: 'Failed to revoke session',
variant: 'destructive'
});
} else {
toast({
title: 'Success',
description: 'Session revoked successfully'
});
fetchSessions();
}
};
const getDeviceIcon = (deviceInfo: any) => {
const ua = deviceInfo?.userAgent?.toLowerCase() || '';
if (ua.includes('mobile')) return <Smartphone className="w-4 h-4" />;
if (ua.includes('tablet')) return <Tablet className="w-4 h-4" />;
return <Monitor className="w-4 h-4" />;
};
if (loading) {
return <div className="text-muted-foreground">Loading sessions...</div>;
}
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Active Sessions</h3>
<p className="text-sm text-muted-foreground">
Manage your active login sessions across devices
</p>
</div>
{sessions.map((session) => (
<Card key={session.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex gap-3">
{getDeviceIcon(session.device_info)}
<div>
<div className="font-medium">
{session.device_info?.browser || 'Unknown Browser'}
</div>
<div className="text-sm text-muted-foreground">
Last active: {format(new Date(session.last_activity), 'PPpp')}
</div>
<div className="text-sm text-muted-foreground">
Expires: {format(new Date(session.expires_at), 'PPpp')}
</div>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => revokeSession(session.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
Revoke
</Button>
</div>
</Card>
))}
{sessions.length === 0 && (
<Card className="p-8 text-center text-muted-foreground">
No active sessions found
</Card>
)}
</div>
);
}

View File

@@ -1,9 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useUnitPreferences } from '@/hooks/useUnitPreferences';
import { import {
convertSpeed, convertValueFromMetric,
convertDistance, getDisplayUnit,
convertHeight,
getSpeedUnit, getSpeedUnit,
getDistanceUnit, getDistanceUnit,
getHeightUnit, getHeightUnit,
@@ -29,47 +28,36 @@ export function MeasurementDisplay({
const { displayValue, unit, alternateDisplay, tooltipText } = useMemo(() => { const { displayValue, unit, alternateDisplay, tooltipText } = useMemo(() => {
const system = unitPreferences.measurement_system; const system = unitPreferences.measurement_system;
let displayValue: number; let metricUnit: string;
let unit: string; let displayUnit: string;
let alternateValue: number; let alternateSystem: MeasurementSystem;
let alternateUnit: string;
switch (type) { switch (type) {
case 'speed': case 'speed':
displayValue = convertSpeed(value, system); metricUnit = 'km/h';
unit = getSpeedUnit(system);
alternateValue = convertSpeed(value, system === 'metric' ? 'imperial' : 'metric');
alternateUnit = getSpeedUnit(system === 'metric' ? 'imperial' : 'metric');
break; break;
case 'distance': case 'distance':
displayValue = convertDistance(value, system); case 'short_distance':
unit = getDistanceUnit(system); metricUnit = 'm';
alternateValue = convertDistance(value, system === 'metric' ? 'imperial' : 'metric');
alternateUnit = getDistanceUnit(system === 'metric' ? 'imperial' : 'metric');
break; break;
case 'height': case 'height':
displayValue = convertHeight(value, system); metricUnit = 'cm';
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');
break; break;
default: default:
displayValue = value; return { displayValue: value, unit: '', alternateDisplay: '', tooltipText: undefined };
unit = '';
alternateValue = value;
alternateUnit = '';
} }
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 alternateDisplay = showBothUnits ? ` (${alternateValue} ${alternateUnit})` : '';
const tooltipText = showBothUnits ? undefined : `${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]); }, [value, type, unitPreferences.measurement_system, showBothUnits]);
return ( return (

View File

@@ -9,6 +9,16 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -43,6 +53,9 @@ export function PhotoManagementDialog({
const [photos, setPhotos] = useState<Photo[]>([]); const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editingPhoto, setEditingPhoto] = useState<Photo | null>(null); 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(); const { toast } = useToast();
useEffect(() => { useEffect(() => {
@@ -77,55 +90,133 @@ export function PhotoManagementDialog({
const deletePhoto = async (photoId: string) => { const handleDeleteClick = (photo: Photo) => {
if (!confirm('Are you sure you want to delete this photo?')) return; setPhotoToDelete(photo);
setDeleteReason('');
setDeleteDialogOpen(true);
};
const requestPhotoDelete = async () => {
if (!photoToDelete || !deleteReason.trim()) return;
try { 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();
await fetchPhotos(); if (submissionError) throw submissionError;
toast({
title: 'Success', // Create submission item
description: 'Photo deleted', 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'
}); });
onUpdate?.();
if (itemError) throw itemError;
toast({
title: 'Delete request submitted',
description: 'Your photo deletion request has been submitted for moderation',
});
setDeleteDialogOpen(false);
setPhotoToDelete(null);
setDeleteReason('');
onOpenChange(false);
} catch (error) { } catch (error) {
console.error('Error deleting photo:', error); console.error('Error requesting photo deletion:', error);
toast({ toast({
title: 'Error', title: 'Error',
description: 'Failed to delete photo', description: 'Failed to submit deletion request',
variant: 'destructive', variant: 'destructive',
}); });
} }
}; };
const updatePhoto = async () => { const requestPhotoEdit = async () => {
if (!editingPhoto) return; if (!editingPhoto) return;
try { try {
const { error } = await supabase // Get current user
.from('photos') const { data: { user } } = await supabase.auth.getUser();
.update({ if (!user) throw new Error('Not authenticated');
caption: editingPhoto.caption,
})
.eq('id', editingPhoto.id);
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); setEditingPhoto(null);
toast({ toast({
title: 'Success', title: 'Edit request submitted',
description: 'Photo updated', description: 'Your photo edit has been submitted for moderation',
}); });
onUpdate?.(); onOpenChange(false);
} catch (error) { } catch (error) {
console.error('Error updating photo:', error); console.error('Error requesting photo edit:', error);
toast({ toast({
title: 'Error', title: 'Error',
description: 'Failed to update photo', description: 'Failed to submit edit request',
variant: 'destructive', variant: 'destructive',
}); });
} }
@@ -167,7 +258,7 @@ export function PhotoManagementDialog({
<Button variant="outline" onClick={() => setEditingPhoto(null)}> <Button variant="outline" onClick={() => setEditingPhoto(null)}>
Cancel Cancel
</Button> </Button>
<Button onClick={updatePhoto}>Save Changes</Button> <Button onClick={requestPhotoEdit}>Submit for Review</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -223,16 +314,16 @@ export function PhotoManagementDialog({
className="flex-1 sm:flex-initial" className="flex-1 sm:flex-initial"
> >
<Pencil className="w-4 h-4 mr-2" /> <Pencil className="w-4 h-4 mr-2" />
Edit Request Edit
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="destructive" variant="destructive"
onClick={() => deletePhoto(photo.id)} onClick={() => handleDeleteClick(photo)}
className="flex-1 sm:flex-initial" className="flex-1 sm:flex-initial"
> >
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
Delete Request Delete
</Button> </Button>
</div> </div>
</div> </div>
@@ -249,6 +340,44 @@ export function PhotoManagementDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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> </Dialog>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -115,6 +115,17 @@ export function useAdminSettings() {
return value === true || value === 'true'; return value === true || value === 'true';
}; };
const getAdminPanelRefreshMode = () => {
const value = getSettingValue('system.admin_panel_refresh_mode', 'auto');
// Remove quotes if they exist (JSON string stored in DB)
return typeof value === 'string' ? value.replace(/"/g, '') : value;
};
const getAdminPanelPollInterval = () => {
const value = getSettingValue('system.admin_panel_poll_interval', 30);
return parseInt(value?.toString() || '30') * 1000; // Convert to milliseconds
};
return { return {
settings, settings,
isLoading, isLoading,
@@ -132,5 +143,7 @@ export function useAdminSettings() {
getReportThreshold, getReportThreshold,
getAuditRetentionDays, getAuditRetentionDays,
getAutoCleanupEnabled, getAutoCleanupEnabled,
getAdminPanelRefreshMode,
getAdminPanelPollInterval,
}; };
} }

View File

@@ -81,8 +81,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
currentEmail !== previousEmail && currentEmail !== previousEmail &&
!newEmailPending !newEmailPending
) { ) {
console.log('Email change confirmed:', { from: previousEmail, to: currentEmail });
// Defer Novu update and notifications to avoid blocking auth // Defer Novu update and notifications to avoid blocking auth
setTimeout(async () => { setTimeout(async () => {
try { try {

View File

@@ -0,0 +1,113 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
interface ModerationStats {
pendingSubmissions: number;
openReports: number;
flaggedContent: number;
}
interface UseModerationStatsOptions {
onStatsChange?: (stats: ModerationStats) => void;
enabled?: boolean;
pollingEnabled?: boolean;
pollingInterval?: number;
}
export const useModerationStats = (options: UseModerationStatsOptions = {}) => {
const {
onStatsChange,
enabled = true,
pollingEnabled = true,
pollingInterval = 30000 // Default 30 seconds
} = options;
const [stats, setStats] = useState<ModerationStats>({
pendingSubmissions: 0,
openReports: 0,
flaggedContent: 0,
});
const [isLoading, setIsLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const onStatsChangeRef = useRef(onStatsChange);
// Update ref when callback changes
useEffect(() => {
onStatsChangeRef.current = onStatsChange;
}, [onStatsChange]);
const fetchStats = useCallback(async (silent = false) => {
if (!enabled) return;
try {
// Only show loading on initial load
if (!silent) {
setIsLoading(true);
}
const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([
supabase
.from('content_submissions')
.select('id', { count: 'exact', head: true })
.eq('status', 'pending'),
supabase
.from('reports')
.select('id', { count: 'exact', head: true })
.eq('status', 'pending'),
supabase
.from('reviews')
.select('id', { count: 'exact', head: true })
.eq('moderation_status', 'flagged'),
]);
const newStats = {
pendingSubmissions: submissionsResult.count || 0,
openReports: reportsResult.count || 0,
flaggedContent: reviewsResult.count || 0,
};
setStats(newStats);
setLastUpdated(new Date());
onStatsChangeRef.current?.(newStats);
} catch (error) {
console.error('Error fetching moderation stats:', error);
} finally {
// Only clear loading if it was set
if (!silent) {
setIsLoading(false);
}
if (isInitialLoad) {
setIsInitialLoad(false);
}
}
}, [enabled, isInitialLoad]);
// Initial fetch
useEffect(() => {
if (enabled) {
fetchStats(false); // Show loading
}
}, [enabled, fetchStats]);
// Polling
useEffect(() => {
if (!enabled || !pollingEnabled || isInitialLoad) return;
const interval = setInterval(() => {
fetchStats(true); // Silent refresh
}, pollingInterval);
return () => {
clearInterval(interval);
};
}, [enabled, pollingEnabled, pollingInterval, fetchStats, isInitialLoad]);
return {
stats,
refresh: fetchStats,
isLoading,
lastUpdated
};
};

View File

@@ -1,131 +0,0 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { RealtimeChannel } from '@supabase/supabase-js';
interface ModerationStats {
pendingSubmissions: number;
openReports: number;
flaggedContent: number;
}
interface UseRealtimeModerationStatsOptions {
onStatsChange?: (stats: ModerationStats) => void;
enabled?: boolean;
debounceMs?: number;
}
export const useRealtimeModerationStats = (options: UseRealtimeModerationStatsOptions = {}) => {
const { onStatsChange, enabled = true, debounceMs = 1000 } = options;
const [stats, setStats] = useState<ModerationStats>({
pendingSubmissions: 0,
openReports: 0,
flaggedContent: 0,
});
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
const updateTimerRef = useRef<NodeJS.Timeout | null>(null);
const onStatsChangeRef = useRef(onStatsChange);
// Update ref when callback changes
useEffect(() => {
onStatsChangeRef.current = onStatsChange;
}, [onStatsChange]);
const fetchStats = useCallback(async () => {
try {
const [submissionsResult, reportsResult, reviewsResult] = await Promise.all([
supabase
.from('content_submissions')
.select('id', { count: 'exact', head: true })
.eq('status', 'pending'),
supabase
.from('reports')
.select('id', { count: 'exact', head: true })
.eq('status', 'pending'),
supabase
.from('reviews')
.select('id', { count: 'exact', head: true })
.eq('moderation_status', 'flagged'),
]);
const newStats = {
pendingSubmissions: submissionsResult.count || 0,
openReports: reportsResult.count || 0,
flaggedContent: reviewsResult.count || 0,
};
setStats(newStats);
onStatsChangeRef.current?.(newStats);
} catch (error) {
console.error('Error fetching moderation stats:', error);
}
}, []);
const debouncedFetchStats = useCallback(() => {
if (updateTimerRef.current) {
clearTimeout(updateTimerRef.current);
}
updateTimerRef.current = setTimeout(fetchStats, debounceMs);
}, [fetchStats, debounceMs]);
useEffect(() => {
if (!enabled) return;
// Initial fetch
fetchStats();
// Set up realtime subscriptions
const realtimeChannel = supabase
.channel('moderation-stats-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'content_submissions',
},
() => {
console.log('Content submissions changed');
debouncedFetchStats();
}
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'reports',
},
() => {
console.log('Reports changed');
debouncedFetchStats();
}
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'reviews',
},
() => {
console.log('Reviews changed');
debouncedFetchStats();
}
)
.subscribe((status) => {
console.log('Moderation stats realtime status:', status);
});
setChannel(realtimeChannel);
return () => {
console.log('Cleaning up moderation stats realtime subscription');
if (updateTimerRef.current) {
clearTimeout(updateTimerRef.current);
}
supabase.removeChannel(realtimeChannel);
};
}, [enabled, fetchStats, debouncedFetchStats]);
return { stats, refresh: fetchStats };
};

View File

@@ -1,54 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { RealtimeChannel } from '@supabase/supabase-js';
interface UseRealtimeSubmissionItemsOptions {
submissionId?: string;
onUpdate?: (payload: any) => void;
enabled?: boolean;
}
export const useRealtimeSubmissionItems = (options: UseRealtimeSubmissionItemsOptions = {}) => {
const { submissionId, onUpdate, enabled = true } = options;
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
// Use ref to store latest callback without triggering re-subscriptions
const onUpdateRef = useRef(onUpdate);
// Update ref when callback changes
useEffect(() => {
onUpdateRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
if (!enabled || !submissionId) return;
const realtimeChannel = supabase
.channel(`submission-items-${submissionId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'submission_items',
filter: `submission_id=eq.${submissionId}`,
},
(payload) => {
console.log('Submission item updated:', payload);
onUpdateRef.current?.(payload);
}
)
.subscribe((status) => {
console.log('Submission items realtime status:', status);
});
setChannel(realtimeChannel);
return () => {
console.log('Cleaning up submission items realtime subscription');
supabase.removeChannel(realtimeChannel);
};
}, [submissionId, enabled]);
return { channel };
};

View File

@@ -1,82 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { RealtimeChannel } from '@supabase/supabase-js';
interface UseRealtimeSubmissionsOptions {
onInsert?: (payload: any) => void;
onUpdate?: (payload: any) => void;
onDelete?: (payload: any) => void;
enabled?: boolean;
}
export const useRealtimeSubmissions = (options: UseRealtimeSubmissionsOptions = {}) => {
const { onInsert, onUpdate, onDelete, enabled = true } = options;
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
// Use refs to store latest callbacks without triggering re-subscriptions
const onInsertRef = useRef(onInsert);
const onUpdateRef = useRef(onUpdate);
const onDeleteRef = useRef(onDelete);
// Update refs when callbacks change
useEffect(() => {
onInsertRef.current = onInsert;
onUpdateRef.current = onUpdate;
onDeleteRef.current = onDelete;
}, [onInsert, onUpdate, onDelete]);
useEffect(() => {
if (!enabled) return;
const realtimeChannel = supabase
.channel('content-submissions-changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'content_submissions',
},
(payload) => {
console.log('Submission inserted:', payload);
onInsertRef.current?.(payload);
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'content_submissions',
},
(payload) => {
console.log('Submission updated:', payload);
onUpdateRef.current?.(payload);
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'content_submissions',
},
(payload) => {
console.log('Submission deleted:', payload);
onDeleteRef.current?.(payload);
}
)
.subscribe((status) => {
console.log('Submissions realtime status:', status);
});
setChannel(realtimeChannel);
return () => {
console.log('Cleaning up submissions realtime subscription');
supabase.removeChannel(realtimeChannel);
};
}, [enabled]);
return { channel };
};

View File

@@ -109,6 +109,7 @@ export function useSearch(options: UseSearchOptions = {}) {
subtitle: `at ${ride.park?.name || 'Unknown Park'}`, subtitle: `at ${ride.park?.name || 'Unknown Park'}`,
image: ride.image_url, image: ride.image_url,
rating: ride.average_rating, rating: ride.average_rating,
slug: ride.slug,
data: ride data: ride
}); });
}); });
@@ -129,6 +130,7 @@ export function useSearch(options: UseSearchOptions = {}) {
title: company.name, title: company.name,
subtitle: company.company_type?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Company', subtitle: company.company_type?.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase()) || 'Company',
image: company.logo_url, image: company.logo_url,
slug: company.slug,
data: company data: company
}); });
}); });

View File

@@ -1101,6 +1101,13 @@ export type Database = {
referencedRelation: "rides" referencedRelation: "rides"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "reviews_user_id_fkey"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["user_id"]
},
] ]
} }
ride_coaster_statistics: { ride_coaster_statistics: {
@@ -1147,82 +1154,6 @@ export type Database = {
}, },
] ]
} }
ride_coaster_stats: {
Row: {
category: string | null
created_at: string
id: string
ride_submission_id: string
stat_name: string
stat_value: number
unit: string | null
}
Insert: {
category?: string | null
created_at?: string
id?: string
ride_submission_id: string
stat_name: string
stat_value: number
unit?: string | null
}
Update: {
category?: string | null
created_at?: string
id?: string
ride_submission_id?: string
stat_name?: string
stat_value?: number
unit?: string | null
}
Relationships: [
{
foreignKeyName: "ride_coaster_stats_ride_submission_id_fkey"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
ride_former_names: {
Row: {
created_at: string
date_changed: string | null
former_name: string
id: string
order_index: number | null
reason: string | null
ride_submission_id: string
}
Insert: {
created_at?: string
date_changed?: string | null
former_name: string
id?: string
order_index?: number | null
reason?: string | null
ride_submission_id: string
}
Update: {
created_at?: string
date_changed?: string | null
former_name?: string
id?: string
order_index?: number | null
reason?: string | null
ride_submission_id?: string
}
Relationships: [
{
foreignKeyName: "ride_former_names_ride_submission_id_fkey"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
ride_model_submissions: { ride_model_submissions: {
Row: { Row: {
banner_image_id: string | null banner_image_id: string | null
@@ -1340,7 +1271,6 @@ export type Database = {
name: string name: string
ride_type: string ride_type: string
slug: string slug: string
technical_specs: Json | null
updated_at: string updated_at: string
} }
Insert: { Insert: {
@@ -1356,7 +1286,6 @@ export type Database = {
name: string name: string
ride_type: string ride_type: string
slug: string slug: string
technical_specs?: Json | null
updated_at?: string updated_at?: string
} }
Update: { Update: {
@@ -1372,7 +1301,6 @@ export type Database = {
name?: string name?: string
ride_type?: string ride_type?: string
slug?: string slug?: string
technical_specs?: Json | null
updated_at?: string updated_at?: string
} }
Relationships: [ Relationships: [
@@ -1429,6 +1357,126 @@ export type Database = {
}, },
] ]
} }
ride_submission_coaster_statistics: {
Row: {
category: string | null
created_at: string
id: string
ride_submission_id: string
stat_name: string
stat_value: number
unit: string | null
}
Insert: {
category?: string | null
created_at?: string
id?: string
ride_submission_id: string
stat_name: string
stat_value: number
unit?: string | null
}
Update: {
category?: string | null
created_at?: string
id?: string
ride_submission_id?: string
stat_name?: string
stat_value?: number
unit?: string | null
}
Relationships: [
{
foreignKeyName: "fk_ride_submission_coaster_statistics_ride_submission_id"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
ride_submission_name_history: {
Row: {
created_at: string
date_changed: string | null
former_name: string
id: string
order_index: number | null
reason: string | null
ride_submission_id: string
}
Insert: {
created_at?: string
date_changed?: string | null
former_name: string
id?: string
order_index?: number | null
reason?: string | null
ride_submission_id: string
}
Update: {
created_at?: string
date_changed?: string | null
former_name?: string
id?: string
order_index?: number | null
reason?: string | null
ride_submission_id?: string
}
Relationships: [
{
foreignKeyName: "fk_ride_submission_name_history_ride_submission_id"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
ride_submission_technical_specifications: {
Row: {
category: string | null
created_at: string
display_order: number | null
id: string
ride_submission_id: string
spec_name: string
spec_type: string
spec_value: string
unit: string | null
}
Insert: {
category?: string | null
created_at?: string
display_order?: number | null
id?: string
ride_submission_id: string
spec_name: string
spec_type: string
spec_value: string
unit?: string | null
}
Update: {
category?: string | null
created_at?: string
display_order?: number | null
id?: string
ride_submission_id?: string
spec_name?: string
spec_type?: string
spec_value?: string
unit?: string | null
}
Relationships: [
{
foreignKeyName: "fk_ride_submission_technical_specifications_ride_submission_id"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
ride_submissions: { ride_submissions: {
Row: { Row: {
age_requirement: number | null age_requirement: number | null
@@ -1592,47 +1640,6 @@ export type Database = {
}, },
] ]
} }
ride_technical_specs: {
Row: {
category: string | null
created_at: string
id: string
ride_submission_id: string
spec_name: string
spec_type: string
spec_value: string
unit: string | null
}
Insert: {
category?: string | null
created_at?: string
id?: string
ride_submission_id: string
spec_name: string
spec_type: string
spec_value: string
unit?: string | null
}
Update: {
category?: string | null
created_at?: string
id?: string
ride_submission_id?: string
spec_name?: string
spec_type?: string
spec_value?: string
unit?: string | null
}
Relationships: [
{
foreignKeyName: "ride_technical_specs_ride_submission_id_fkey"
columns: ["ride_submission_id"]
isOneToOne: false
referencedRelation: "ride_submissions"
referencedColumns: ["id"]
},
]
}
rides: { rides: {
Row: { Row: {
age_requirement: number | null age_requirement: number | null
@@ -1644,14 +1651,12 @@ export type Database = {
card_image_url: string | null card_image_url: string | null
category: string category: string
closing_date: string | null closing_date: string | null
coaster_stats: Json | null
coaster_type: string | null coaster_type: string | null
created_at: string created_at: string
description: string | null description: string | null
designer_id: string | null designer_id: string | null
drop_height_meters: number | null drop_height_meters: number | null
duration_seconds: number | null duration_seconds: number | null
former_names: Json | null
height_requirement: number | null height_requirement: number | null
id: string id: string
image_url: string | null image_url: string | null
@@ -1671,7 +1676,6 @@ export type Database = {
seating_type: string | null seating_type: string | null
slug: string slug: string
status: string status: string
technical_specs: Json | null
updated_at: string updated_at: string
} }
Insert: { Insert: {
@@ -1684,14 +1688,12 @@ export type Database = {
card_image_url?: string | null card_image_url?: string | null
category: string category: string
closing_date?: string | null closing_date?: string | null
coaster_stats?: Json | null
coaster_type?: string | null coaster_type?: string | null
created_at?: string created_at?: string
description?: string | null description?: string | null
designer_id?: string | null designer_id?: string | null
drop_height_meters?: number | null drop_height_meters?: number | null
duration_seconds?: number | null duration_seconds?: number | null
former_names?: Json | null
height_requirement?: number | null height_requirement?: number | null
id?: string id?: string
image_url?: string | null image_url?: string | null
@@ -1711,7 +1713,6 @@ export type Database = {
seating_type?: string | null seating_type?: string | null
slug: string slug: string
status?: string status?: string
technical_specs?: Json | null
updated_at?: string updated_at?: string
} }
Update: { Update: {
@@ -1724,14 +1725,12 @@ export type Database = {
card_image_url?: string | null card_image_url?: string | null
category?: string category?: string
closing_date?: string | null closing_date?: string | null
coaster_stats?: Json | null
coaster_type?: string | null coaster_type?: string | null
created_at?: string created_at?: string
description?: string | null description?: string | null
designer_id?: string | null designer_id?: string | null
drop_height_meters?: number | null drop_height_meters?: number | null
duration_seconds?: number | null duration_seconds?: number | null
former_names?: Json | null
height_requirement?: number | null height_requirement?: number | null
id?: string id?: string
image_url?: string | null image_url?: string | null
@@ -1751,7 +1750,6 @@ export type Database = {
seating_type?: string | null seating_type?: string | null
slug?: string slug?: string
status?: string status?: string
technical_specs?: Json | null
updated_at?: string updated_at?: string
} }
Relationships: [ Relationships: [
@@ -2040,6 +2038,7 @@ export type Database = {
expires_at: string expires_at: string
id: string id: string
ip_address: unknown | null ip_address: unknown | null
ip_address_hash: string | null
last_activity: string last_activity: string
session_token: string session_token: string
user_agent: string | null user_agent: string | null
@@ -2051,6 +2050,7 @@ export type Database = {
expires_at?: string expires_at?: string
id?: string id?: string
ip_address?: unknown | null ip_address?: unknown | null
ip_address_hash?: string | null
last_activity?: string last_activity?: string
session_token: string session_token: string
user_agent?: string | null user_agent?: string | null
@@ -2062,6 +2062,7 @@ export type Database = {
expires_at?: string expires_at?: string
id?: string id?: string
ip_address?: unknown | null ip_address?: unknown | null
ip_address_hash?: string | null
last_activity?: string last_activity?: string
session_token?: string session_token?: string
user_agent?: string | null user_agent?: string | null
@@ -2116,7 +2117,6 @@ export type Database = {
description: string | null description: string | null
id: string id: string
is_public: boolean | null is_public: boolean | null
items: Json
list_type: string list_type: string
title: string title: string
updated_at: string updated_at: string
@@ -2127,7 +2127,6 @@ export type Database = {
description?: string | null description?: string | null
id?: string id?: string
is_public?: boolean | null is_public?: boolean | null
items: Json
list_type: string list_type: string
title: string title: string
updated_at?: string updated_at?: string
@@ -2138,7 +2137,6 @@ export type Database = {
description?: string | null description?: string | null
id?: string id?: string
is_public?: boolean | null is_public?: boolean | null
items?: Json
list_type?: string list_type?: string
title?: string title?: string
updated_at?: string updated_at?: string
@@ -2175,6 +2173,14 @@ export type Database = {
Args: { _user_id: string } Args: { _user_id: string }
Returns: boolean Returns: boolean
} }
check_realtime_access: {
Args: Record<PropertyKey, never>
Returns: boolean
}
cleanup_expired_sessions: {
Args: Record<PropertyKey, never>
Returns: undefined
}
extract_cf_image_id: { extract_cf_image_id: {
Args: { url: string } Args: { url: string }
Returns: string Returns: string
@@ -2206,6 +2212,10 @@ export type Database = {
} }
Returns: boolean Returns: boolean
} }
hash_ip_address: {
Args: { ip_text: string }
Returns: string
}
is_moderator: { is_moderator: {
Args: { _user_id: string } Args: { _user_id: string }
Returns: boolean Returns: boolean

View File

@@ -75,10 +75,10 @@ export async function submitCompanyUpdate(
data: CompanyFormData, data: CompanyFormData,
userId: string userId: string
) { ) {
// Fetch existing company to get its type // Fetch existing company data (all fields for original_data)
const { data: existingCompany, error: fetchError } = await supabase const { data: existingCompany, error: fetchError } = await supabase
.from('companies') .from('companies')
.select('company_type') .select('*')
.eq('id', companyId) .eq('id', companyId)
.single(); .single();
@@ -112,7 +112,7 @@ export async function submitCompanyUpdate(
if (submissionError) throw submissionError; if (submissionError) throw submissionError;
// Create the submission item with actual company data // Create the submission item with actual company data AND original data
const { error: itemError } = await supabase const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
.insert({ .insert({
@@ -129,6 +129,7 @@ export async function submitCompanyUpdate(
headquarters_location: data.headquarters_location, headquarters_location: data.headquarters_location,
images: processedImages as any images: processedImages as any
}, },
original_data: JSON.parse(JSON.stringify(existingCompany)),
status: 'pending', status: 'pending',
order_index: 0 order_index: 0
}); });

View File

@@ -2,6 +2,52 @@ import { supabase } from '@/integrations/supabase/client';
import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader'; import { ImageAssignments } from '@/components/upload/EntityMultiImageUploader';
import { uploadPendingImages } from './imageUploadHelper'; import { uploadPendingImages } from './imageUploadHelper';
/**
* ═══════════════════════════════════════════════════════════════════
* SUBMISSION PATTERN STANDARD - CRITICAL PROJECT RULE
* ═══════════════════════════════════════════════════════════════════
*
* ⚠️ NEVER STORE JSON IN SQL COLUMNS ⚠️
*
* content_submissions.content should ONLY contain:
* ✅ action: 'create' | 'edit' | 'delete'
* ✅ Minimal reference IDs (entity_id, parent_id, etc.) - MAX 3 fields
* ❌ NO actual form data
* ❌ NO submission content
* ❌ NO large objects
*
* ALL actual data MUST go in:
* ✅ submission_items.item_data (new data)
* ✅ submission_items.original_data (for edits)
* ✅ Specialized relational tables:
* - photo_submissions + photo_submission_items
* - park_submissions
* - ride_submissions
* - company_submissions
* - ride_model_submissions
*
* If your data is relational, model it relationally.
* JSON blobs destroy:
* - Queryability (can't filter/join)
* - Performance (slower, larger)
* - Data integrity (no constraints)
* - Maintainability (impossible to refactor)
*
* EXAMPLES:
*
* ✅ CORRECT:
* content: { action: 'create' }
* content: { action: 'edit', park_id: uuid }
* content: { action: 'delete', photo_id: uuid }
*
* ❌ WRONG:
* content: { name: '...', description: '...', ...formData }
* content: { photos: [...], metadata: {...} }
* content: data // entire object dump
*
* ═══════════════════════════════════════════════════════════════════
*/
export interface ParkFormData { export interface ParkFormData {
name: string; name: string;
slug: string; slug: string;
@@ -15,7 +61,21 @@ export interface ParkFormData {
email?: string; email?: string;
operator_id?: string; operator_id?: string;
property_owner_id?: string; property_owner_id?: string;
// Location can be stored as object for new submissions or ID for editing
location?: {
name: string;
city?: string;
state_province?: string;
country: string;
postal_code?: string;
latitude: number;
longitude: number;
timezone?: string;
display_name: string;
};
location_id?: string; location_id?: string;
images?: ImageAssignments; images?: ImageAssignments;
banner_image_url?: string; banner_image_url?: string;
banner_image_id?: string; banner_image_id?: string;
@@ -49,9 +109,6 @@ export interface RideFormData {
coaster_type?: string; coaster_type?: string;
seating_type?: string; seating_type?: string;
ride_sub_type?: string; ride_sub_type?: string;
coaster_stats?: any;
technical_specs?: any;
former_names?: any;
images?: ImageAssignments; images?: ImageAssignments;
banner_image_url?: string; banner_image_url?: string;
banner_image_id?: string; banner_image_id?: string;
@@ -113,6 +170,16 @@ export async function submitParkUpdate(
data: ParkFormData, data: ParkFormData,
userId: string userId: string
) { ) {
// Fetch existing park data first
const { data: existingPark, error: fetchError } = await supabase
.from('parks')
.select('*')
.eq('id', parkId)
.single();
if (fetchError) throw new Error(`Failed to fetch park: ${fetchError.message}`);
if (!existingPark) throw new Error('Park not found');
// Upload any pending local images first // Upload any pending local images first
let processedImages = data.images; let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) { if (data.images?.uploaded && data.images.uploaded.length > 0) {
@@ -140,7 +207,7 @@ export async function submitParkUpdate(
if (submissionError) throw submissionError; if (submissionError) throw submissionError;
// Create the submission item with actual park data // Create the submission item with actual park data AND original data
const { error: itemError } = await supabase const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
.insert({ .insert({
@@ -151,6 +218,7 @@ export async function submitParkUpdate(
park_id: parkId, park_id: parkId,
images: processedImages as any images: processedImages as any
}, },
original_data: JSON.parse(JSON.stringify(existingPark)),
status: 'pending', status: 'pending',
order_index: 0 order_index: 0
}); });
@@ -214,6 +282,16 @@ export async function submitRideUpdate(
data: RideFormData, data: RideFormData,
userId: string userId: string
) { ) {
// Fetch existing ride data first
const { data: existingRide, error: fetchError } = await supabase
.from('rides')
.select('*')
.eq('id', rideId)
.single();
if (fetchError) throw new Error(`Failed to fetch ride: ${fetchError.message}`);
if (!existingRide) throw new Error('Ride not found');
// Upload any pending local images first // Upload any pending local images first
let processedImages = data.images; let processedImages = data.images;
if (data.images?.uploaded && data.images.uploaded.length > 0) { if (data.images?.uploaded && data.images.uploaded.length > 0) {
@@ -241,7 +319,7 @@ export async function submitRideUpdate(
if (submissionError) throw submissionError; if (submissionError) throw submissionError;
// Create the submission item with actual ride data // Create the submission item with actual ride data AND original data
const { error: itemError } = await supabase const { error: itemError } = await supabase
.from('submission_items') .from('submission_items')
.insert({ .insert({
@@ -252,6 +330,7 @@ export async function submitRideUpdate(
ride_id: rideId, ride_id: rideId,
images: processedImages as any images: processedImages as any
}, },
original_data: JSON.parse(JSON.stringify(existingRide)),
status: 'pending', status: 'pending',
order_index: 0 order_index: 0
}); });

View File

@@ -1,4 +1,10 @@
import { Database } from '@/integrations/supabase/types'; import { Database } from '@/integrations/supabase/types';
import type {
ParkSubmissionData,
RideSubmissionData,
CompanySubmissionData,
RideModelSubmissionData,
} from '@/types/submission-data';
type ParkInsert = Database['public']['Tables']['parks']['Insert']; type ParkInsert = Database['public']['Tables']['parks']['Insert'];
type RideInsert = Database['public']['Tables']['rides']['Insert']; type RideInsert = Database['public']['Tables']['rides']['Insert'];
@@ -7,8 +13,10 @@ type RideModelInsert = Database['public']['Tables']['ride_models']['Insert'];
/** /**
* Transform park submission data to database insert format * Transform park submission data to database insert format
* @param submissionData - Validated park submission data
* @returns Database insert object for parks table
*/ */
export function transformParkData(submissionData: any): ParkInsert { export function transformParkData(submissionData: ParkSubmissionData): ParkInsert {
return { return {
name: submissionData.name, name: submissionData.name,
slug: submissionData.slug, slug: submissionData.slug,
@@ -38,8 +46,10 @@ export function transformParkData(submissionData: any): ParkInsert {
* Transform ride submission data to database insert format * Transform ride submission data to database insert format
* Note: Relational data (technical_specs, coaster_stats, former_names) are now * Note: Relational data (technical_specs, coaster_stats, former_names) are now
* stored in separate tables and should not be included in the main ride insert. * stored in separate tables and should not be included in the main ride insert.
* @param submissionData - Validated ride submission data
* @returns Database insert object for rides table
*/ */
export function transformRideData(submissionData: any): RideInsert { export function transformRideData(submissionData: RideSubmissionData): RideInsert {
return { return {
name: submissionData.name, name: submissionData.name,
slug: submissionData.slug, slug: submissionData.slug,
@@ -78,9 +88,12 @@ export function transformRideData(submissionData: any): RideInsert {
/** /**
* Transform company submission data to database insert format * Transform company submission data to database insert format
* @param submissionData - Validated company submission data
* @param companyType - Type of company (manufacturer, operator, property_owner, designer)
* @returns Database insert object for companies table
*/ */
export function transformCompanyData( export function transformCompanyData(
submissionData: any, submissionData: CompanySubmissionData,
companyType: 'manufacturer' | 'operator' | 'property_owner' | 'designer' companyType: 'manufacturer' | 'operator' | 'property_owner' | 'designer'
): CompanyInsert { ): CompanyInsert {
return { return {
@@ -102,8 +115,10 @@ export function transformCompanyData(
* Transform ride model submission data to database insert format * Transform ride model submission data to database insert format
* Note: Technical specifications are now stored in the ride_model_technical_specifications * Note: Technical specifications are now stored in the ride_model_technical_specifications
* table and should not be included in the main ride model insert. * table and should not be included in the main ride model insert.
* @param submissionData - Validated ride model submission data
* @returns Database insert object for ride_models table
*/ */
export function transformRideModelData(submissionData: any): RideModelInsert { export function transformRideModelData(submissionData: RideModelSubmissionData): RideModelInsert {
return { return {
name: submissionData.name, name: submissionData.name,
slug: submissionData.slug, slug: submissionData.slug,
@@ -150,8 +165,14 @@ export function extractImageId(url: string): string {
/** /**
* Validate and sanitize submission data before transformation * Validate and sanitize submission data before transformation
* @param data - Submission data to validate
* @param itemType - Type of entity being validated (for error messages)
* @throws Error if validation fails
*/ */
export function validateSubmissionData(data: any, itemType: string): void { export function validateSubmissionData(
data: ParkSubmissionData | RideSubmissionData | CompanySubmissionData | RideModelSubmissionData,
itemType: string
): void {
if (!data.name || typeof data.name !== 'string' || data.name.trim() === '') { if (!data.name || typeof data.name !== 'string' || data.name.trim() === '') {
throw new Error(`${itemType} name is required`); throw new Error(`${itemType} name is required`);
} }

View File

@@ -0,0 +1,328 @@
import type { SubmissionItemData } from '@/types/submissions';
import { supabase } from '@/integrations/supabase/client';
export interface FieldChange {
field: string;
oldValue: any;
newValue: any;
changeType: 'added' | 'removed' | 'modified';
}
export interface ImageChange {
type: 'banner' | 'card';
oldUrl?: string;
newUrl?: string;
oldId?: string;
newId?: string;
}
export interface PhotoChange {
type: 'added' | 'edited' | 'deleted';
photos?: Array<{ url: string; title?: string; caption?: string }>;
photo?: {
url: string;
title?: string;
caption?: string;
oldCaption?: string;
newCaption?: string;
oldTitle?: string;
newTitle?: string;
};
}
export interface ChangesSummary {
action: 'create' | 'edit' | 'delete';
entityType: string;
entityName?: string;
fieldChanges: FieldChange[];
imageChanges: ImageChange[];
photoChanges: PhotoChange[];
hasLocationChange: boolean;
totalChanges: number;
}
/**
* Detects photo changes for a submission
*/
async function detectPhotoChanges(submissionId: string): Promise<PhotoChange[]> {
const changes: PhotoChange[] = [];
try {
// Fetch photo submission with items - use array query to avoid 406 errors
const { data: photoSubmissions, error } = await supabase
.from('photo_submissions')
.select(`
*,
items:photo_submission_items(*)
`)
.eq('submission_id', submissionId);
if (error) {
console.error('Error fetching photo submissions:', error);
return changes;
}
const photoSubmission = photoSubmissions?.[0];
if (photoSubmission?.items && photoSubmission.items.length > 0) {
// For now, treat all photos as additions
// TODO: Implement edit/delete detection by comparing with existing entity photos
changes.push({
type: 'added',
photos: photoSubmission.items.map((item: any) => ({
url: item.cloudflare_image_url,
title: item.title,
caption: item.caption
}))
});
}
} catch (err) {
console.error('Error detecting photo changes:', err);
}
return changes;
}
/**
* Detects what changed between original_data and item_data
*/
export async function detectChanges(
item: { item_data?: any; original_data?: any; item_type: string },
submissionId?: string
): Promise<ChangesSummary> {
const itemData = item.item_data || {};
const originalData = item.original_data || {};
// Determine action type
const action: 'create' | 'edit' | 'delete' =
!originalData || Object.keys(originalData).length === 0 ? 'create' :
itemData.deleted ? 'delete' : 'edit';
const fieldChanges: FieldChange[] = [];
const imageChanges: ImageChange[] = [];
let hasLocationChange = false;
if (action === 'create') {
// For creates, all fields are "added"
Object.entries(itemData).forEach(([key, value]) => {
if (shouldTrackField(key) && value !== null && value !== undefined && value !== '') {
fieldChanges.push({
field: key,
oldValue: null,
newValue: value,
changeType: 'added',
});
}
});
} else if (action === 'edit') {
// Compare each field
const allKeys = new Set([
...Object.keys(itemData),
...Object.keys(originalData)
]);
allKeys.forEach(key => {
if (!shouldTrackField(key)) return;
const oldValue = originalData[key];
const newValue = itemData[key];
// Handle location changes specially
if (key === 'location' || key === 'location_id') {
if (!isEqual(oldValue, newValue)) {
hasLocationChange = true;
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'modified',
});
}
return;
}
// Check for changes
if (!isEqual(oldValue, newValue)) {
if ((oldValue === null || oldValue === undefined || oldValue === '') && newValue) {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'added',
});
} else if ((newValue === null || newValue === undefined || newValue === '') && oldValue) {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'removed',
});
} else {
fieldChanges.push({
field: key,
oldValue,
newValue,
changeType: 'modified',
});
}
}
});
// Detect image changes
detectImageChanges(originalData, itemData, imageChanges);
}
// Get entity name
const entityName = itemData.name || originalData?.name || 'Unknown';
// Detect photo changes if submissionId provided
const photoChanges = submissionId ? await detectPhotoChanges(submissionId) : [];
return {
action,
entityType: item.item_type,
entityName,
fieldChanges,
imageChanges,
photoChanges,
hasLocationChange,
totalChanges: fieldChanges.length + imageChanges.length + photoChanges.length + (hasLocationChange ? 1 : 0)
};
}
/**
* Determines if a field should be tracked for changes
*/
function shouldTrackField(key: string): boolean {
const excludedFields = [
'id',
'created_at',
'updated_at',
'slug',
'image_assignments',
'banner_image_url',
'banner_image_id',
'card_image_url',
'card_image_id',
];
return !excludedFields.includes(key);
}
/**
* Deep equality check for values
*/
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;
}
/**
* Detects changes in banner/card images
*/
function detectImageChanges(
originalData: any,
itemData: any,
imageChanges: ImageChange[]
): void {
// Check banner image
if (originalData.banner_image_id !== itemData.banner_image_id ||
originalData.banner_image_url !== itemData.banner_image_url) {
imageChanges.push({
type: 'banner',
oldUrl: originalData.banner_image_url,
newUrl: itemData.banner_image_url,
oldId: originalData.banner_image_id,
newId: itemData.banner_image_id,
});
}
// Check card image
if (originalData.card_image_id !== itemData.card_image_id ||
originalData.card_image_url !== itemData.card_image_url) {
imageChanges.push({
type: 'card',
oldUrl: originalData.card_image_url,
newUrl: itemData.card_image_url,
oldId: originalData.card_image_id,
newId: itemData.card_image_id,
});
}
}
/**
* Format field name for display
*/
export function formatFieldName(field: string): string {
return field
.replace(/_/g, ' ')
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim();
}
/**
* Format field value for display
*/
export function formatFieldValue(value: any): string {
if (value === null || value === undefined) return 'None';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
// Handle dates
if (value instanceof Date || (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value))) {
try {
const date = new Date(value);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
} catch {
return String(value);
}
}
// Handle arrays - show actual items
if (Array.isArray(value)) {
if (value.length === 0) return 'None';
if (value.length <= 3) return value.map(v => String(v)).join(', ');
return `${value.slice(0, 3).map(v => String(v)).join(', ')}... +${value.length - 3} more`;
}
// Handle objects - create readable summary
if (typeof value === 'object') {
// Location object
if (value.city || value.state_province || value.country) {
const parts = [value.city, value.state_province, value.country].filter(Boolean);
return parts.join(', ');
}
// Generic object - show key-value pairs
const entries = Object.entries(value).slice(0, 3);
if (entries.length === 0) return 'Empty';
return entries.map(([k, v]) => `${k}: ${v}`).join(', ');
}
// Handle URLs
if (typeof value === 'string' && value.startsWith('http')) {
try {
const url = new URL(value);
return url.hostname + (url.pathname !== '/' ? url.pathname.slice(0, 30) : '');
} catch {
return value;
}
}
if (typeof value === 'number') return value.toLocaleString();
return String(value);
}

View File

@@ -294,6 +294,12 @@ async function createPark(data: any, dependencyMap: Map<string, string>): Promis
// Handle park edit // Handle park edit
const resolvedData = resolveDependencies(data, dependencyMap); const resolvedData = resolveDependencies(data, dependencyMap);
// Resolve location_id if location data is provided
let locationId = resolvedData.location_id;
if (resolvedData.location && !locationId) {
locationId = await resolveLocationId(resolvedData.location);
}
// Extract image assignments from ImageAssignments structure // Extract image assignments from ImageAssignments structure
const imageData = extractImageAssignments(resolvedData.images); const imageData = extractImageAssignments(resolvedData.images);
@@ -311,7 +317,7 @@ async function createPark(data: any, dependencyMap: Map<string, string>): Promis
email: resolvedData.email || null, email: resolvedData.email || null,
operator_id: resolvedData.operator_id || null, operator_id: resolvedData.operator_id || null,
property_owner_id: resolvedData.property_owner_id || null, property_owner_id: resolvedData.property_owner_id || null,
location_id: resolvedData.location_id || null, location_id: locationId || null,
...imageData, ...imageData,
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}; };
@@ -333,6 +339,12 @@ async function createPark(data: any, dependencyMap: Map<string, string>): Promis
validateSubmissionData(data, 'Park'); validateSubmissionData(data, 'Park');
const resolvedData = resolveDependencies(data, dependencyMap); const resolvedData = resolveDependencies(data, dependencyMap);
// Resolve location_id if location data is provided
let locationId = resolvedData.location_id;
if (resolvedData.location && !locationId) {
locationId = await resolveLocationId(resolvedData.location);
}
// Ensure unique slug // Ensure unique slug
const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'parks'); const uniqueSlug = await ensureUniqueSlug(resolvedData.slug, 'parks');
resolvedData.slug = uniqueSlug; resolvedData.slug = uniqueSlug;
@@ -341,7 +353,11 @@ async function createPark(data: any, dependencyMap: Map<string, string>): Promis
const imageData = extractImageAssignments(resolvedData.images); const imageData = extractImageAssignments(resolvedData.images);
// Transform to database format // Transform to database format
const parkData = { ...transformParkData(resolvedData), ...imageData }; const parkData = {
...transformParkData(resolvedData),
...imageData,
location_id: locationId || null,
};
// Insert into database // Insert into database
const { data: park, error } = await supabase const { data: park, error } = await supabase
@@ -358,6 +374,51 @@ async function createPark(data: any, dependencyMap: Map<string, string>): Promis
return park.id; return park.id;
} }
/**
* Resolve location data to a location_id
* Checks for existing locations by coordinates, creates new ones if needed
*/
async function resolveLocationId(locationData: any): Promise<string | null> {
if (!locationData || !locationData.latitude || !locationData.longitude) {
return null;
}
// Check if location already exists by coordinates
const { data: existingLocation } = await supabase
.from('locations')
.select('id')
.eq('latitude', locationData.latitude)
.eq('longitude', locationData.longitude)
.maybeSingle();
if (existingLocation) {
return existingLocation.id;
}
// Create new location (moderator has permission via RLS)
const { data: newLocation, error } = await supabase
.from('locations')
.insert({
name: locationData.name,
city: locationData.city || null,
state_province: locationData.state_province || null,
country: locationData.country,
postal_code: locationData.postal_code || null,
latitude: locationData.latitude,
longitude: locationData.longitude,
timezone: locationData.timezone || null,
})
.select('id')
.single();
if (error) {
console.error('Error creating location:', error);
throw new Error(`Failed to create location: ${error.message}`);
}
return newLocation.id;
}
async function createRide(data: any, dependencyMap: Map<string, string>): Promise<string> { async function createRide(data: any, dependencyMap: Map<string, string>): Promise<string> {
const { transformRideData, validateSubmissionData } = await import('./entityTransformers'); const { transformRideData, validateSubmissionData } = await import('./entityTransformers');
const { ensureUniqueSlug } = await import('./slugUtils'); const { ensureUniqueSlug } = await import('./slugUtils');

View File

@@ -6,53 +6,7 @@ export interface UnitPreferences {
auto_detect: boolean; auto_detect: boolean;
} }
// Speed conversions // Get unit labels (helper functions - use getDisplayUnit for conversion logic)
export function convertSpeed(kmh: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(kmh * 0.621371);
}
return Math.round(kmh);
}
// Distance conversions (meters to feet)
export function convertDistance(meters: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(meters * 3.28084);
}
return Math.round(meters);
}
// Height conversions (cm to inches)
export function convertHeight(cm: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(cm * 0.393701);
}
return Math.round(cm);
}
// Reverse conversions (for form inputs - imperial to metric)
export function convertSpeedToMetric(value: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(value / 0.621371);
}
return Math.round(value);
}
export function convertDistanceToMetric(value: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(value / 3.28084);
}
return Math.round(value);
}
export function convertHeightToMetric(value: number, system: MeasurementSystem): number {
if (system === 'imperial') {
return Math.round(value / 0.393701);
}
return Math.round(value);
}
// Get unit labels
export function getSpeedUnit(system: MeasurementSystem): string { export function getSpeedUnit(system: MeasurementSystem): string {
return system === 'imperial' ? 'mph' : 'km/h'; return system === 'imperial' ? 'mph' : 'km/h';
} }
@@ -76,3 +30,150 @@ export const IMPERIAL_COUNTRIES = ['US', 'LR', 'MM'];
export function getMeasurementSystemFromCountry(countryCode: string): MeasurementSystem { export function getMeasurementSystemFromCountry(countryCode: string): MeasurementSystem {
return IMPERIAL_COUNTRIES.includes(countryCode.toUpperCase()) ? 'imperial' : 'metric'; return IMPERIAL_COUNTRIES.includes(countryCode.toUpperCase()) ? 'imperial' : 'metric';
} }
// Unit type detection
export type UnitType = 'speed' | 'distance' | 'height' | 'weight' | 'unknown';
export function detectUnitType(unit: string): UnitType {
const normalized = unit.toLowerCase().trim();
// Speed units
if (['km/h', 'kmh', 'kph', 'mph', 'm/s', 'ms'].includes(normalized)) {
return 'speed';
}
// Distance units (meters/feet)
if (['m', 'meter', 'meters', 'metre', 'metres', 'ft', 'feet', 'foot'].includes(normalized)) {
return 'distance';
}
// Height units (cm/inches)
if (['cm', 'centimeter', 'centimeters', 'in', 'inch', 'inches'].includes(normalized)) {
return 'height';
}
// Weight units
if (['kg', 'kilogram', 'kilograms', 'lb', 'lbs', 'pound', 'pounds'].includes(normalized)) {
return 'weight';
}
return 'unknown';
}
// Convert any value to metric based on its unit
export function convertValueToMetric(value: number, unit: string): number {
const normalized = unit.toLowerCase().trim();
// Speed conversions to km/h
if (normalized === 'mph') {
return Math.round(value / 0.621371);
}
if (['m/s', 'ms'].includes(normalized)) {
return Math.round(value * 3.6);
}
if (['km/h', 'kmh', 'kph'].includes(normalized)) {
return Math.round(value);
}
// Distance conversions to meters
if (['ft', 'feet', 'foot'].includes(normalized)) {
return Math.round(value / 3.28084);
}
if (['m', 'meter', 'meters', 'metre', 'metres'].includes(normalized)) {
return Math.round(value);
}
// Height conversions to cm
if (['in', 'inch', 'inches'].includes(normalized)) {
return Math.round(value / 0.393701);
}
if (['cm', 'centimeter', 'centimeters'].includes(normalized)) {
return Math.round(value);
}
// Weight conversions to kg
if (['lb', 'lbs', 'pound', 'pounds'].includes(normalized)) {
return Math.round(value / 2.20462);
}
if (['kg', 'kilogram', 'kilograms'].includes(normalized)) {
return Math.round(value);
}
// Unknown unit, return as-is
return value;
}
// Convert metric value to target unit
export function convertValueFromMetric(value: number, targetUnit: string, metricUnit: string): number {
const normalized = targetUnit.toLowerCase().trim();
const metricNormalized = metricUnit.toLowerCase().trim();
// Speed conversions from km/h
if (metricNormalized === 'km/h' || metricNormalized === 'kmh' || metricNormalized === 'kph') {
if (normalized === 'mph') {
return Math.round(value * 0.621371);
}
if (normalized === 'm/s' || normalized === 'ms') {
return Math.round(value / 3.6);
}
}
// Distance conversions from meters
if (metricNormalized === 'm' || metricNormalized === 'meter' || metricNormalized === 'meters') {
if (['ft', 'feet', 'foot'].includes(normalized)) {
return Math.round(value * 3.28084);
}
}
// Height conversions from cm
if (metricNormalized === 'cm' || metricNormalized === 'centimeter' || metricNormalized === 'centimeters') {
if (['in', 'inch', 'inches'].includes(normalized)) {
return Math.round(value * 0.393701);
}
}
// Weight conversions from kg
if (metricNormalized === 'kg' || metricNormalized === 'kilogram' || metricNormalized === 'kilograms') {
if (['lb', 'lbs', 'pound', 'pounds'].includes(normalized)) {
return Math.round(value * 2.20462);
}
}
return value;
}
// Get metric unit for a given unit type
export function getMetricUnit(unit: string): string {
const unitType = detectUnitType(unit);
switch (unitType) {
case 'speed':
return 'km/h';
case 'distance':
return 'm';
case 'height':
return 'cm';
case 'weight':
return 'kg';
default:
return unit;
}
}
// Get display unit based on unit type and measurement system
export function getDisplayUnit(metricUnit: string, system: MeasurementSystem): string {
const unitType = detectUnitType(metricUnit);
switch (unitType) {
case 'speed':
return system === 'imperial' ? 'mph' : 'km/h';
case 'distance':
return system === 'imperial' ? 'ft' : 'm';
case 'height':
return system === 'imperial' ? 'in' : 'cm';
case 'weight':
return system === 'imperial' ? 'lbs' : 'kg';
default:
return metricUnit;
}
}

View File

@@ -1,6 +1,6 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Shield, Users, FileText, Flag, AlertCircle } from 'lucide-react'; import { Shield, Users, FileText, Flag, AlertCircle, RefreshCw } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
@@ -8,11 +8,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue'; import { ModerationQueue, ModerationQueueRef } from '@/components/moderation/ModerationQueue';
import { ReportsQueue } from '@/components/moderation/ReportsQueue'; import { ReportsQueue, ReportsQueueRef } from '@/components/moderation/ReportsQueue';
import { UserManagement } from '@/components/admin/UserManagement'; import { UserManagement } from '@/components/admin/UserManagement';
import { AdminHeader } from '@/components/layout/AdminHeader'; import { AdminHeader } from '@/components/layout/AdminHeader';
import { supabase } from '@/integrations/supabase/client'; import { useModerationStats } from '@/hooks/useModerationStats';
import { useRealtimeModerationStats } from '@/hooks/useRealtimeModerationStats'; import { useAdminSettings } from '@/hooks/useAdminSettings';
export default function Admin() { export default function Admin() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -20,25 +20,30 @@ export default function Admin() {
const { isModerator, loading: roleLoading } = useUserRole(); const { isModerator, loading: roleLoading } = useUserRole();
const navigate = useNavigate(); const navigate = useNavigate();
const moderationQueueRef = useRef<ModerationQueueRef>(null); const moderationQueueRef = useRef<ModerationQueueRef>(null);
const reportsQueueRef = useRef<ReportsQueueRef>(null);
// Use realtime stats hook for live updates // Get admin settings for polling configuration
const { stats: realtimeStats, refresh: refreshStats } = useRealtimeModerationStats({ const {
onStatsChange: (newStats) => { getAdminPanelRefreshMode,
console.log('Stats updated in real-time:', newStats); getAdminPanelPollInterval,
}, isLoading: settingsLoading
} = useAdminSettings();
const refreshMode = getAdminPanelRefreshMode();
const pollInterval = getAdminPanelPollInterval();
// Use stats hook with configurable polling
const { stats, refresh: refreshStats, lastUpdated } = useModerationStats({
enabled: !!user && !authLoading && !roleLoading && isModerator(), enabled: !!user && !authLoading && !roleLoading && isModerator(),
pollingEnabled: refreshMode === 'auto',
pollingInterval: pollInterval,
}); });
const [isFetching, setIsFetching] = useState(false);
const fetchStats = useCallback(async () => {
refreshStats();
}, [refreshStats]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
moderationQueueRef.current?.refresh(); moderationQueueRef.current?.refresh();
fetchStats(); // Also refresh stats reportsQueueRef.current?.refresh();
}, []); refreshStats();
}, [refreshStats]);
useEffect(() => { useEffect(() => {
if (!authLoading && !roleLoading) { if (!authLoading && !roleLoading) {
@@ -75,7 +80,26 @@ export default function Admin() {
<> <>
<AdminHeader onRefresh={handleRefresh} /> <AdminHeader onRefresh={handleRefresh} />
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-3 gap-3 md:gap-6 mb-8"> <div className="space-y-4 mb-8">
{/* Refresh status indicator */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4" />
{refreshMode === 'auto' ? (
<span>Auto-refresh: every {pollInterval / 1000}s</span>
) : (
<span>Manual refresh only</span>
)}
{lastUpdated && (
<span className="text-xs">
Last updated: {lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
</div>
{/* Stats cards */}
<div className="grid grid-cols-3 gap-3 md:gap-6">
<Card> <Card>
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center"> <CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
<FileText className="h-4 w-4 text-muted-foreground mb-2" /> <FileText className="h-4 w-4 text-muted-foreground mb-2" />
@@ -83,7 +107,7 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent className="text-center"> <CardContent className="text-center">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{realtimeStats.pendingSubmissions} {stats.pendingSubmissions}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -95,7 +119,7 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent className="text-center"> <CardContent className="text-center">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{realtimeStats.openReports} {stats.openReports}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -107,11 +131,12 @@ export default function Admin() {
</CardHeader> </CardHeader>
<CardContent className="text-center"> <CardContent className="text-center">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">
{realtimeStats.flaggedContent} {stats.flaggedContent}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div>
{/* Content Moderation Section */} {/* Content Moderation Section */}
<Card className="mb-8"> <Card className="mb-8">
@@ -139,7 +164,7 @@ export default function Admin() {
</TabsContent> </TabsContent>
<TabsContent value="reports"> <TabsContent value="reports">
<ReportsQueue /> <ReportsQueue ref={reportsQueueRef} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</CardContent> </CardContent>

View File

@@ -251,6 +251,79 @@ export default function AdminSettings() {
); );
} }
// Admin panel refresh mode setting
if (setting.setting_key === 'system.admin_panel_refresh_mode') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-blue-500" />
<Label className="text-base font-medium">Admin Panel Refresh Mode</Label>
</div>
<p className="text-sm text-muted-foreground">
Choose how the admin panel statistics refresh
</p>
<div className="flex items-center gap-4">
<Select
value={typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue}
onValueChange={(value) => {
setLocalValue(value);
updateSetting(setting.setting_key, JSON.stringify(value));
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual Only</SelectItem>
<SelectItem value="auto">Auto-refresh</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">
Current: {(typeof localValue === 'string' ? localValue.replace(/"/g, '') : localValue) === 'auto' ? 'Auto-refresh' : 'Manual'}
</Badge>
</div>
</div>
</Card>
);
}
// Admin panel poll interval setting
if (setting.setting_key === 'system.admin_panel_poll_interval') {
return (
<Card className="p-4">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-green-500" />
<Label className="text-base font-medium">Auto-refresh Interval</Label>
</div>
<p className="text-sm text-muted-foreground">
How often to automatically refresh admin panel statistics (when auto-refresh is enabled)
</p>
<div className="flex items-center gap-4">
<Select value={localValue?.toString()} onValueChange={(value) => {
const numValue = parseInt(value);
setLocalValue(numValue);
updateSetting(setting.setting_key, numValue);
}}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10 seconds</SelectItem>
<SelectItem value="30">30 seconds</SelectItem>
<SelectItem value="60">1 minute</SelectItem>
<SelectItem value="120">2 minutes</SelectItem>
<SelectItem value="300">5 minutes</SelectItem>
</SelectContent>
</Select>
<Badge variant="outline">Current: {localValue}s</Badge>
</div>
</div>
</Card>
);
}
// Boolean/switch settings // Boolean/switch settings
if (setting.setting_key.includes('email_alerts') || if (setting.setting_key.includes('email_alerts') ||
setting.setting_key.includes('require_approval') || setting.setting_key.includes('require_approval') ||

View File

@@ -240,7 +240,18 @@ export default function DesignerDetail() {
<TabsContent value="rides"> <TabsContent value="rides">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-muted-foreground">Rides designed by {designer.name} will be displayed here.</p> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Rides</h2>
<Button
variant="outline"
onClick={() => navigate(`/designers/${designer.slug}/rides`)}
>
View All Rides
</Button>
</div>
<p className="text-muted-foreground">
View all rides designed by {designer.name}
</p>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

252
src/pages/DesignerRides.tsx Normal file
View File

@@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel } from 'lucide-react';
import { Ride, Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { RideCard } from '@/components/rides/RideCard';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
export default function DesignerRides() {
const { designerSlug } = useParams<{ designerSlug: string }>();
const navigate = useNavigate();
const [designer, setDesigner] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
useEffect(() => {
if (designerSlug) {
fetchData();
}
}, [designerSlug, sortBy, filterCategory, filterStatus]);
const fetchData = async () => {
try {
// Fetch designer
const { data: designerData, error: designerError } = await supabase
.from('companies')
.select('*')
.eq('slug', designerSlug)
.eq('company_type', 'designer')
.maybeSingle();
if (designerError) throw designerError;
setDesigner(designerData);
if (designerData) {
// Fetch rides designed by this company
let query = supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*)
`)
.eq('designer_id', designerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('max_height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
setRides(ridesData || []);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!designer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Designer Not Found</h1>
<Button onClick={() => navigate('/designers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Designers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/designers/${designerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {designer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Rides by {designer.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all rides designed by {designer.name}
</p>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredRides.length} rides
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm px-2 py-0.5">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or park..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredRides.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-6">
{filteredRides.map((ride) => (
<RideCard key={ride.id} ride={ride} showParkName={true} />
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
{designer.name} hasn't designed any rides matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -220,9 +220,10 @@ export default function ManufacturerDetail() {
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="overview" className="w-full"> <Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-2 md:grid-cols-3"> <TabsList className="grid w-full grid-cols-2 md:grid-cols-4">
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rides">Rides</TabsTrigger> <TabsTrigger value="rides">Rides</TabsTrigger>
<TabsTrigger value="models">Models</TabsTrigger>
<TabsTrigger value="photos">Photos</TabsTrigger> <TabsTrigger value="photos">Photos</TabsTrigger>
</TabsList> </TabsList>
@@ -242,7 +243,37 @@ export default function ManufacturerDetail() {
<TabsContent value="rides"> <TabsContent value="rides">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-muted-foreground">Rides manufactured by {manufacturer.name} will be displayed here.</p> <div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Rides</h2>
<Button
variant="outline"
onClick={() => navigate(`/manufacturers/${manufacturer.slug}/rides`)}
>
View All Rides
</Button>
</div>
<p className="text-muted-foreground">
View all rides manufactured by {manufacturer.name}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models">
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Models</h2>
<Button
variant="outline"
onClick={() => navigate(`/manufacturers/${manufacturer.slug}/models`)}
>
View All Models
</Button>
</div>
<p className="text-muted-foreground">
View all ride models by {manufacturer.name}
</p>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View File

@@ -0,0 +1,212 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel } from 'lucide-react';
import { RideModel, Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { RideModelCard } from '@/components/rides/RideModelCard';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
export default function ManufacturerModels() {
const { manufacturerSlug } = useParams<{ manufacturerSlug: string }>();
const navigate = useNavigate();
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [models, setModels] = useState<RideModel[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
useEffect(() => {
if (manufacturerSlug) {
fetchData();
}
}, [manufacturerSlug, sortBy, filterCategory]);
const fetchData = async () => {
try {
// Fetch manufacturer
const { data: manufacturerData, error: manufacturerError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug)
.eq('company_type', 'manufacturer')
.maybeSingle();
if (manufacturerError) throw manufacturerError;
setManufacturer(manufacturerData);
if (manufacturerData) {
// Fetch ride models with ride count
let query = supabase
.from('ride_models')
.select(`
*,
rides:rides(count)
`)
.eq('manufacturer_id', manufacturerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
switch (sortBy) {
case 'name':
query = query.order('name');
break;
default:
query = query.order('name');
}
const { data: modelsData, error: modelsError } = await query;
if (modelsError) throw modelsError;
setModels((modelsData || []) as any);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const filteredModels = models.filter(model =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Manufacturer Not Found</h1>
<Button onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Manufacturers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Models by {manufacturer.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all ride models manufactured by {manufacturer.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredModels.length} models
</Badge>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search models by name..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredModels.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
{filteredModels.map((model) => (
<RideModelCard
key={model.id}
model={model}
manufacturerSlug={manufacturerSlug || ''}
/>
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No models found</h3>
<p className="text-muted-foreground">
{manufacturer.name} doesn't have any models matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ArrowLeft, Filter, SlidersHorizontal, FerrisWheel } from 'lucide-react';
import { Ride, Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { RideCard } from '@/components/rides/RideCard';
import { AutocompleteSearch } from '@/components/search/AutocompleteSearch';
export default function ManufacturerRides() {
const { manufacturerSlug } = useParams<{ manufacturerSlug: string }>();
const navigate = useNavigate();
const [manufacturer, setManufacturer] = useState<Company | null>(null);
const [rides, setRides] = useState<Ride[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState('name');
const [filterCategory, setFilterCategory] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
useEffect(() => {
if (manufacturerSlug) {
fetchData();
}
}, [manufacturerSlug, sortBy, filterCategory, filterStatus]);
const fetchData = async () => {
try {
// Fetch manufacturer
const { data: manufacturerData, error: manufacturerError } = await supabase
.from('companies')
.select('*')
.eq('slug', manufacturerSlug)
.eq('company_type', 'manufacturer')
.maybeSingle();
if (manufacturerError) throw manufacturerError;
setManufacturer(manufacturerData);
if (manufacturerData) {
// Fetch rides manufactured by this company
let query = supabase
.from('rides')
.select(`
*,
park:parks!inner(name, slug, location:locations(*)),
manufacturer:companies!rides_manufacturer_id_fkey(*)
`)
.eq('manufacturer_id', manufacturerData.id);
if (filterCategory !== 'all') {
query = query.eq('category', filterCategory);
}
if (filterStatus !== 'all') {
query = query.eq('status', filterStatus);
}
switch (sortBy) {
case 'rating':
query = query.order('average_rating', { ascending: false });
break;
case 'speed':
query = query.order('max_speed_kmh', { ascending: false, nullsFirst: false });
break;
case 'height':
query = query.order('max_height_meters', { ascending: false, nullsFirst: false });
break;
case 'reviews':
query = query.order('review_count', { ascending: false });
break;
default:
query = query.order('name');
}
const { data: ridesData, error: ridesError } = await query;
if (ridesError) throw ridesError;
setRides(ridesData || []);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const filteredRides = rides.filter(ride =>
ride.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
ride.park?.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const categories = [
{ value: 'all', label: 'All Categories' },
{ value: 'roller_coaster', label: 'Roller Coasters' },
{ value: 'flat_ride', label: 'Flat Rides' },
{ value: 'water_ride', label: 'Water Rides' },
{ value: 'dark_ride', label: 'Dark Rides' },
{ value: 'kiddie_ride', label: 'Kiddie Rides' },
{ value: 'transportation', label: 'Transportation' }
];
const statusOptions = [
{ value: 'all', label: 'All Status' },
{ value: 'operating', label: 'Operating' },
{ value: 'seasonal', label: 'Seasonal' },
{ value: 'under_construction', label: 'Under Construction' },
{ value: 'closed', label: 'Closed' }
];
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!manufacturer) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Manufacturer Not Found</h1>
<Button onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Manufacturers
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/manufacturers/${manufacturerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {manufacturer.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<FerrisWheel className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Rides by {manufacturer.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all rides manufactured by {manufacturer.name}
</p>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredRides.length} rides
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm px-2 py-0.5">
{rides.filter(r => r.category === 'roller_coaster').length} coasters
</Badge>
</div>
</div>
<div className="mb-8 space-y-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<AutocompleteSearch
placeholder="Search rides by name or park..."
types={['ride']}
limit={8}
onSearch={(query) => setSearchQuery(query)}
showRecentSearches={false}
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[180px]">
<SlidersHorizontal className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="rating">Highest Rated</SelectItem>
<SelectItem value="speed">Fastest</SelectItem>
<SelectItem value="height">Tallest</SelectItem>
<SelectItem value="reviews">Most Reviews</SelectItem>
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="w-[160px]">
<Filter className="w-4 h-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.value} value={category.value}>
{category.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statusOptions.map(status => (
<SelectItem key={status.value} value={status.value}>
{status.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{filteredRides.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-6">
{filteredRides.map((ride) => (
<RideCard key={ride.id} ride={ride} showParkName={true} />
))}
</div>
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 mx-auto text-muted-foreground" />
<h3 className="text-xl font-semibold mb-2">No rides found</h3>
<p className="text-muted-foreground">
{manufacturer.name} hasn't manufactured any rides matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { OperatorForm } from '@/components/admin/OperatorForm'; import { OperatorForm } from '@/components/admin/OperatorForm';
import { OperatorPhotoGallery } from '@/components/companies/OperatorPhotoGallery'; import { OperatorPhotoGallery } from '@/components/companies/OperatorPhotoGallery';
import { ParkCard } from '@/components/parks/ParkCard';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
@@ -20,7 +21,9 @@ export default function OperatorDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [operator, setOperator] = useState<Company | null>(null); const [operator, setOperator] = useState<Company | null>(null);
const [parks, setParks] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
@@ -42,6 +45,11 @@ export default function OperatorDetail() {
if (error) throw error; if (error) throw error;
setOperator(data); setOperator(data);
// Fetch parks operated by this operator
if (data) {
fetchParks(data.id);
}
} catch (error) { } catch (error) {
console.error('Error fetching operator:', error); console.error('Error fetching operator:', error);
} finally { } finally {
@@ -49,6 +57,27 @@ export default function OperatorDetail() {
} }
}; };
const fetchParks = async (operatorId: string) => {
try {
const { data, error } = await supabase
.from('parks')
.select(`
*,
location:locations(*)
`)
.eq('operator_id', operatorId)
.order('name')
.limit(6);
if (error) throw error;
setParks(data || []);
} catch (error) {
console.error('Error fetching parks:', error);
} finally {
setParksLoading(false);
}
};
const handleEditSubmit = async (data: any) => { const handleEditSubmit = async (data: any) => {
try { try {
await submitCompanyUpdate( await submitCompanyUpdate(
@@ -240,7 +269,34 @@ export default function OperatorDetail() {
<TabsContent value="parks"> <TabsContent value="parks">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-muted-foreground">Parks operated by {operator.name} will be displayed here.</p> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Parks Operated</h2>
<Button
variant="outline"
onClick={() => navigate(`/operators/${operator.slug}/parks`)}
>
View All Parks
</Button>
</div>
{parksLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
</div>
) : parks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<FerrisWheel className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No parks found for {operator.name}</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

278
src/pages/OperatorParks.tsx Normal file
View File

@@ -0,0 +1,278 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, MapPin, Filter, FerrisWheel } from 'lucide-react';
import { Park, Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { ParkGridView } from '@/components/parks/ParkGridView';
import { ParkListView } from '@/components/parks/ParkListView';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ParkSortOptions } from '@/components/parks/ParkSortOptions';
import { ParkFilters } from '@/components/parks/ParkFilters';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Grid3X3, List } from 'lucide-react';
import { FilterState, SortState } from './Parks';
const initialFilters: FilterState = {
search: '',
parkType: 'all',
status: 'all',
country: 'all',
minRating: 0,
maxRating: 5,
minRides: 0,
maxRides: 1000,
openingYearStart: null,
openingYearEnd: null,
};
const initialSort: SortState = {
field: 'name',
direction: 'asc'
};
export default function OperatorParks() {
const { operatorSlug } = useParams<{ operatorSlug: string }>();
const navigate = useNavigate();
const [operator, setOperator] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
if (operatorSlug) {
fetchData();
}
}, [operatorSlug]);
const fetchData = async () => {
try {
// Fetch operator
const { data: operatorData, error: operatorError } = await supabase
.from('companies')
.select('*')
.eq('slug', operatorSlug)
.eq('company_type', 'operator')
.maybeSingle();
if (operatorError) throw operatorError;
setOperator(operatorData);
if (operatorData) {
// Fetch parks operated by this operator
const { data: parksData, error: parksError } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*),
property_owner:companies!parks_property_owner_id_fkey(*)
`)
.eq('operator_id', operatorData.id)
.order('name');
if (parksError) throw parksError;
setParks(parksData || []);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const filteredAndSortedParks = useMemo(() => {
let filtered = parks.filter(park => {
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const matchesSearch =
park.name.toLowerCase().includes(searchTerm) ||
park.description?.toLowerCase().includes(searchTerm) ||
park.location?.city?.toLowerCase().includes(searchTerm) ||
park.location?.country?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
if (filters.parkType !== 'all' && park.park_type !== filters.parkType) return false;
if (filters.status !== 'all' && park.status !== filters.status) return false;
if (filters.country !== 'all' && park.location?.country !== filters.country) return false;
const rating = park.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) return false;
const rideCount = park.ride_count || 0;
if (rideCount < filters.minRides || rideCount > filters.maxRides) return false;
return true;
});
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sort.field) {
case 'name':
aValue = a.name;
bValue = b.name;
break;
case 'rating':
aValue = a.average_rating || 0;
bValue = b.average_rating || 0;
break;
case 'rides':
aValue = a.ride_count || 0;
bValue = b.ride_count || 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sort.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return sort.direction === 'asc' ? aValue - bValue : bValue - aValue;
});
return filtered;
}, [parks, filters, sort]);
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!operator) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Operator Not Found</h1>
<Button onClick={() => navigate('/operators')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Operators
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/operators/${operatorSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {operator.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Parks Operated by {operator.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all theme parks operated by {operator.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredAndSortedParks.length} parks
</Badge>
</div>
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<ParkSearch
value={filters.search}
onChange={(search) => setFilters(prev => ({ ...prev, search }))}
/>
</div>
<div className="flex gap-2 w-full lg:w-auto">
<div className="flex-1 sm:flex-none">
<ParkSortOptions
sort={sort}
onSortChange={setSort}
/>
</div>
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="shrink-0"
>
<Filter className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">Filters</span>
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{showFilters && (
<Card>
<CardContent className="p-6">
<ParkFilters
filters={filters}
onFiltersChange={setFilters}
parks={parks}
/>
</CardContent>
</Card>
)}
</div>
{filteredAndSortedParks.length > 0 ? (
viewMode === 'grid' ? (
<ParkGridView parks={filteredAndSortedParks} />
) : (
<ParkListView
parks={filteredAndSortedParks}
onParkClick={(park) => navigate(`/parks/${park.slug}`)}
/>
)
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No parks found</h3>
<p className="text-muted-foreground">
{operator.name} doesn't operate any parks matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

278
src/pages/OwnerParks.tsx Normal file
View File

@@ -0,0 +1,278 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { ArrowLeft, MapPin, Filter, FerrisWheel } from 'lucide-react';
import { Park, Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client';
import { ParkGridView } from '@/components/parks/ParkGridView';
import { ParkListView } from '@/components/parks/ParkListView';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ParkSortOptions } from '@/components/parks/ParkSortOptions';
import { ParkFilters } from '@/components/parks/ParkFilters';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Grid3X3, List } from 'lucide-react';
import { FilterState, SortState } from './Parks';
const initialFilters: FilterState = {
search: '',
parkType: 'all',
status: 'all',
country: 'all',
minRating: 0,
maxRating: 5,
minRides: 0,
maxRides: 1000,
openingYearStart: null,
openingYearEnd: null,
};
const initialSort: SortState = {
field: 'name',
direction: 'asc'
};
export default function OwnerParks() {
const { ownerSlug } = useParams<{ ownerSlug: string }>();
const navigate = useNavigate();
const [owner, setOwner] = useState<Company | null>(null);
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<FilterState>(initialFilters);
const [sort, setSort] = useState<SortState>(initialSort);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
if (ownerSlug) {
fetchData();
}
}, [ownerSlug]);
const fetchData = async () => {
try {
// Fetch owner
const { data: ownerData, error: ownerError } = await supabase
.from('companies')
.select('*')
.eq('slug', ownerSlug)
.eq('company_type', 'property_owner')
.maybeSingle();
if (ownerError) throw ownerError;
setOwner(ownerData);
if (ownerData) {
// Fetch parks owned by this property owner
const { data: parksData, error: parksError } = await supabase
.from('parks')
.select(`
*,
location:locations(*),
operator:companies!parks_operator_id_fkey(*),
property_owner:companies!parks_property_owner_id_fkey(*)
`)
.eq('property_owner_id', ownerData.id)
.order('name');
if (parksError) throw parksError;
setParks(parksData || []);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
const filteredAndSortedParks = useMemo(() => {
let filtered = parks.filter(park => {
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
const matchesSearch =
park.name.toLowerCase().includes(searchTerm) ||
park.description?.toLowerCase().includes(searchTerm) ||
park.location?.city?.toLowerCase().includes(searchTerm) ||
park.location?.country?.toLowerCase().includes(searchTerm);
if (!matchesSearch) return false;
}
if (filters.parkType !== 'all' && park.park_type !== filters.parkType) return false;
if (filters.status !== 'all' && park.status !== filters.status) return false;
if (filters.country !== 'all' && park.location?.country !== filters.country) return false;
const rating = park.average_rating || 0;
if (rating < filters.minRating || rating > filters.maxRating) return false;
const rideCount = park.ride_count || 0;
if (rideCount < filters.minRides || rideCount > filters.maxRides) return false;
return true;
});
filtered.sort((a, b) => {
let aValue: any, bValue: any;
switch (sort.field) {
case 'name':
aValue = a.name;
bValue = b.name;
break;
case 'rating':
aValue = a.average_rating || 0;
bValue = b.average_rating || 0;
break;
case 'rides':
aValue = a.ride_count || 0;
bValue = b.ride_count || 0;
break;
default:
aValue = a.name;
bValue = b.name;
}
if (typeof aValue === 'string' && typeof bValue === 'string') {
return sort.direction === 'asc' ? aValue.localeCompare(bValue) : bValue.localeCompare(aValue);
}
return sort.direction === 'asc' ? aValue - bValue : bValue - aValue;
});
return filtered;
}, [parks, filters, sort]);
if (loading) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-12 bg-muted rounded w-1/3"></div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="h-64 bg-muted rounded-lg"></div>
))}
</div>
</div>
</div>
</div>
);
}
if (!owner) {
return (
<div className="min-h-screen bg-background">
<Header />
<div className="container mx-auto px-4 py-8">
<div className="text-center py-12">
<h1 className="text-2xl font-bold mb-4">Property Owner Not Found</h1>
<Button onClick={() => navigate('/owners')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Property Owners
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="container mx-auto px-4 py-8">
<div className="mb-6">
<Button variant="ghost" onClick={() => navigate(`/owners/${ownerSlug}`)}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to {owner.name}
</Button>
</div>
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<MapPin className="w-8 h-8 text-primary" />
<h1 className="text-4xl font-bold">Parks Owned by {owner.name}</h1>
</div>
<p className="text-lg text-muted-foreground mb-4">
Explore all theme parks owned by {owner.name}
</p>
<Badge variant="secondary" className="text-sm sm:text-base px-2 py-0.5 sm:px-3 sm:py-1">
{filteredAndSortedParks.length} parks
</Badge>
</div>
<div className="mb-6 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1">
<ParkSearch
value={filters.search}
onChange={(search) => setFilters(prev => ({ ...prev, search }))}
/>
</div>
<div className="flex gap-2 w-full lg:w-auto">
<div className="flex-1 sm:flex-none">
<ParkSortOptions
sort={sort}
onSortChange={setSort}
/>
</div>
<Button
variant={showFilters ? "default" : "outline"}
onClick={() => setShowFilters(!showFilters)}
className="shrink-0"
>
<Filter className="w-4 h-4 md:mr-2" />
<span className="hidden md:inline">Filters</span>
</Button>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)} className="hidden md:inline-flex">
<TabsList>
<TabsTrigger value="grid">
<Grid3X3 className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="list">
<List className="w-4 h-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
{showFilters && (
<Card>
<CardContent className="p-6">
<ParkFilters
filters={filters}
onFiltersChange={setFilters}
parks={parks}
/>
</CardContent>
</Card>
)}
</div>
{filteredAndSortedParks.length > 0 ? (
viewMode === 'grid' ? (
<ParkGridView parks={filteredAndSortedParks} />
) : (
<ParkListView
parks={filteredAndSortedParks}
onParkClick={(park) => navigate(`/parks/${park.slug}`)}
/>
)
) : (
<div className="text-center py-12">
<FerrisWheel className="w-16 h-16 mb-4 opacity-50 mx-auto" />
<h3 className="text-xl font-semibold mb-2">No parks found</h3>
<p className="text-muted-foreground">
{owner.name} doesn't own any parks matching your criteria
</p>
</div>
)}
</main>
</div>
);
}

View File

@@ -110,47 +110,18 @@ export default function ParkDetail() {
} }
try { try {
// Extract composite submission data const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
const compositeData = rideData._compositeSubmission; await submitRideCreation(
delete rideData._compositeSubmission; {
...rideData,
// Determine submission type based on what's being created park_id: park?.id
let submissionType = 'ride'; },
if (compositeData?.new_manufacturer && compositeData?.new_ride_model) { user.id
submissionType = 'ride_with_manufacturer_and_model'; );
} else if (compositeData?.new_manufacturer) {
submissionType = 'ride_with_manufacturer';
} else if (compositeData?.new_ride_model) {
submissionType = 'ride_with_model';
}
const { error } = await supabase
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: submissionType,
status: 'pending',
content: {
...(compositeData || { ride: rideData }),
park_id: park?.id,
park_slug: park?.slug
}
});
if (error) throw error;
let message = "Your ride submission has been sent for moderation review.";
if (compositeData?.new_manufacturer && compositeData?.new_ride_model) {
message = "Your ride, new manufacturer, and new model have been submitted for review.";
} else if (compositeData?.new_manufacturer) {
message = "Your ride and new manufacturer have been submitted for review.";
} else if (compositeData?.new_ride_model) {
message = "Your ride and new model have been submitted for review.";
}
toast({ toast({
title: "Submission Sent", title: "Submission Sent",
description: message, description: "Your ride submission has been sent for moderation review.",
}); });
setIsAddRideModalOpen(false); setIsAddRideModalOpen(false);
@@ -410,10 +381,21 @@ export default function ParkDetail() {
key={ride.id} key={ride.id}
ride={ride} ride={ride}
showParkName={false} showParkName={false}
parkSlug={park.slug}
className="h-full" className="h-full"
/> />
))} ))}
</div> </div>
{rides.length > 4 && (
<div className="mt-4 text-center">
<Button
variant="outline"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -561,6 +543,7 @@ export default function ParkDetail() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{rides.map(ride => ( {rides.map(ride => (
<RideCard <RideCard
@@ -571,6 +554,16 @@ export default function ParkDetail() {
/> />
))} ))}
</div> </div>
<div className="mt-8 text-center">
<Button
variant="outline"
size="lg"
onClick={() => navigate(`/parks/${park.slug}/rides/`)}
>
View All {park.ride_count} Rides
</Button>
</div>
</>
)} )}
</TabsContent> </TabsContent>

View File

@@ -11,6 +11,7 @@ import { Company } from '@/types/database';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { PropertyOwnerForm } from '@/components/admin/PropertyOwnerForm'; import { PropertyOwnerForm } from '@/components/admin/PropertyOwnerForm';
import { PropertyOwnerPhotoGallery } from '@/components/companies/PropertyOwnerPhotoGallery'; import { PropertyOwnerPhotoGallery } from '@/components/companies/PropertyOwnerPhotoGallery';
import { ParkCard } from '@/components/parks/ParkCard';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole'; import { useUserRole } from '@/hooks/useUserRole';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
@@ -20,7 +21,9 @@ export default function PropertyOwnerDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [owner, setOwner] = useState<Company | null>(null); const [owner, setOwner] = useState<Company | null>(null);
const [parks, setParks] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [parksLoading, setParksLoading] = useState(true);
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
const { isModerator } = useUserRole(); const { isModerator } = useUserRole();
@@ -42,6 +45,11 @@ export default function PropertyOwnerDetail() {
if (error) throw error; if (error) throw error;
setOwner(data); setOwner(data);
// Fetch parks owned by this property owner
if (data) {
fetchParks(data.id);
}
} catch (error) { } catch (error) {
console.error('Error fetching property owner:', error); console.error('Error fetching property owner:', error);
} finally { } finally {
@@ -49,6 +57,27 @@ export default function PropertyOwnerDetail() {
} }
}; };
const fetchParks = async (ownerId: string) => {
try {
const { data, error } = await supabase
.from('parks')
.select(`
*,
location:locations(*)
`)
.eq('property_owner_id', ownerId)
.order('name')
.limit(6);
if (error) throw error;
setParks(data || []);
} catch (error) {
console.error('Error fetching parks:', error);
} finally {
setParksLoading(false);
}
};
const handleEditSubmit = async (data: any) => { const handleEditSubmit = async (data: any) => {
try { try {
await submitCompanyUpdate( await submitCompanyUpdate(
@@ -240,7 +269,34 @@ export default function PropertyOwnerDetail() {
<TabsContent value="parks"> <TabsContent value="parks">
<Card> <Card>
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-muted-foreground">Parks owned by {owner.name} will be displayed here.</p> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Parks Owned</h2>
<Button
variant="outline"
onClick={() => navigate(`/owners/${owner.slug}/parks`)}
>
View All Parks
</Button>
</div>
{parksLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-64 bg-muted rounded-lg animate-pulse" />
))}
</div>
) : parks.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Building2 className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No parks found for {owner.name}</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View File

@@ -30,7 +30,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { ReviewsSection } from '@/components/reviews/ReviewsSection'; import { ReviewsSection } from '@/components/reviews/ReviewsSection';
import { MeasurementDisplay } from '@/components/ui/measurement-display'; import { MeasurementDisplay } from '@/components/ui/measurement-display';
import { RidePhotoGallery } from '@/components/rides/RidePhotoGallery'; import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
import { RatingDistribution } from '@/components/rides/RatingDistribution'; import { RatingDistribution } from '@/components/rides/RatingDistribution';
import { RideHighlights } from '@/components/rides/RideHighlights'; import { RideHighlights } from '@/components/rides/RideHighlights';
import { SimilarRides } from '@/components/rides/SimilarRides'; import { SimilarRides } from '@/components/rides/SimilarRides';
@@ -261,18 +261,6 @@ export default function RideDetail() {
{ride.average_rating.toFixed(1)} {ride.average_rating.toFixed(1)}
</span> </span>
</div> </div>
<div className="flex items-center justify-center gap-1 mb-3">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-4 h-4 ${
star <= Math.round(ride.average_rating)
? 'fill-yellow-400 text-yellow-400'
: 'text-white/40'
}`}
/>
))}
</div>
<div className="text-white/90 text-sm mb-3"> <div className="text-white/90 text-sm mb-3">
{ride.review_count} {ride.review_count === 1 ? "review" : "reviews"} {ride.review_count} {ride.review_count === 1 ? "review" : "reviews"}
</div> </div>
@@ -346,7 +334,7 @@ export default function RideDetail() {
</Card> </Card>
)} )}
{ride.inversions && ride.inversions > 0 && ( {ride.inversions !== null && ride.inversions !== undefined && (
<Card> <Card>
<CardContent className="p-4 text-center"> <CardContent className="p-4 text-center">
<RotateCcw className="w-6 h-6 text-accent mx-auto mb-2" /> <RotateCcw className="w-6 h-6 text-accent mx-auto mb-2" />
@@ -380,29 +368,6 @@ export default function RideDetail() {
)} )}
</div> </div>
{/* Requirements & Warnings */}
{(ride.height_requirement || ride.age_requirement) && (
<Card className="mb-8 border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-950">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-orange-600 mt-0.5" />
<div>
<h3 className="font-semibold text-orange-800 dark:text-orange-200 mb-2">
Ride Requirements
</h3>
<div className="text-sm space-y-1">
{ride.height_requirement && (
<div>Minimum height: <MeasurementDisplay value={ride.height_requirement} type="height" /></div>
)}
{ride.age_requirement && (
<div>Minimum age: {ride.age_requirement} years</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Main Content */} {/* Main Content */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
@@ -430,8 +395,8 @@ export default function RideDetail() {
</Card> </Card>
)} )}
{ride.former_names && ride.former_names.length > 0 && ( {ride.name_history && ride.name_history.length > 0 && (
<FormerNames formerNames={ride.former_names} currentName={ride.name} /> <FormerNames nameHistory={ride.name_history} currentName={ride.name} />
)} )}
<SimilarRides <SimilarRides
@@ -673,10 +638,11 @@ export default function RideDetail() {
</TabsContent> </TabsContent>
<TabsContent value="photos" className="mt-6"> <TabsContent value="photos" className="mt-6">
<RidePhotoGallery <EntityPhotoGallery
rideId={ride.id} entityId={ride.id}
rideName={ride.name} entityType="ride"
parkId={(ride as any).currentParkId} entityName={ride.name}
parentId={(ride as any).currentParkId}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@@ -83,17 +83,8 @@ export default function Rides() {
return; return;
} }
// All users submit for moderation const { submitRideCreation } = await import('@/lib/entitySubmissionHelpers');
const { error } = await supabase await submitRideCreation(data, user.id);
.from('content_submissions')
.insert({
user_id: user.id,
submission_type: 'ride',
status: 'pending',
content: data
});
if (error) throw error;
toast({ toast({
title: "Ride Submitted", title: "Ride Submitted",

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Settings, User, Shield, Eye, Bell, MapPin, Download } from 'lucide-react'; import { Settings, User, Shield, Eye, Bell, MapPin, Download, MonitorSmartphone } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Navigate } from 'react-router-dom'; import { Navigate } from 'react-router-dom';
import { Header } from '@/components/layout/Header'; import { Header } from '@/components/layout/Header';
import { AccountProfileTab } from '@/components/settings/AccountProfileTab'; import { AccountProfileTab } from '@/components/settings/AccountProfileTab';
import { SecurityTab } from '@/components/settings/SecurityTab'; import { SecurityTab } from '@/components/settings/SecurityTab';
import { SessionsTab } from '@/components/settings/SessionsTab';
import { PrivacyTab } from '@/components/settings/PrivacyTab'; import { PrivacyTab } from '@/components/settings/PrivacyTab';
import { NotificationsTab } from '@/components/settings/NotificationsTab'; import { NotificationsTab } from '@/components/settings/NotificationsTab';
import { LocationTab } from '@/components/settings/LocationTab'; import { LocationTab } from '@/components/settings/LocationTab';
@@ -49,6 +50,12 @@ export default function UserSettings() {
icon: Shield, icon: Shield,
component: SecurityTab component: SecurityTab
}, },
{
id: 'sessions',
label: 'Sessions',
icon: MonitorSmartphone,
component: SessionsTab
},
{ {
id: 'privacy', id: 'privacy',
label: 'Privacy', label: 'Privacy',
@@ -91,7 +98,7 @@ export default function UserSettings() {
{/* Settings Tabs */} {/* Settings Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6 h-auto p-1"> <TabsList className="grid w-full grid-cols-2 lg:grid-cols-7 h-auto p-1">
{tabs.map((tab) => { {tabs.map((tab) => {
const Icon = tab.icon; const Icon = tab.icon;
return ( return (

View File

@@ -112,8 +112,7 @@ export interface RideModel {
category: 'roller_coaster' | 'flat_ride' | 'water_ride' | 'dark_ride' | 'kiddie_ride' | 'transportation'; category: 'roller_coaster' | 'flat_ride' | 'water_ride' | 'dark_ride' | 'kiddie_ride' | 'transportation';
ride_type: string; ride_type: string;
description?: string; description?: string;
technical_specs?: any; // Legacy JSON field technical_specifications?: RideModelTechnicalSpec[];
technical_specifications?: RideModelTechnicalSpec[]; // New relational data
} }
export interface Ride { export interface Ride {
@@ -138,10 +137,6 @@ export interface Ride {
max_height_meters?: number; max_height_meters?: number;
length_meters?: number; length_meters?: number;
inversions?: number; inversions?: number;
coaster_stats?: any; // Legacy JSON field
technical_specs?: any; // Legacy JSON field
former_names?: any; // Legacy JSON field
// New relational data
technical_specifications?: RideTechnicalSpec[]; technical_specifications?: RideTechnicalSpec[];
coaster_statistics?: RideCoasterStat[]; coaster_statistics?: RideCoasterStat[];
name_history?: RideNameHistory[]; name_history?: RideNameHistory[];

View File

@@ -0,0 +1,85 @@
/**
* Type definitions for submission data structures
* These replace the `any` types in entityTransformers.ts
*/
export interface ParkSubmissionData {
name: string;
slug: string;
description?: string | null;
park_type: string;
status: string;
opening_date?: string | null;
closing_date?: string | null;
website_url?: string | null;
phone?: string | null;
email?: string | null;
operator_id?: string | null;
property_owner_id?: string | null;
location_id?: string | null;
banner_image_url?: string | null;
banner_image_id?: string | null;
card_image_url?: string | null;
card_image_id?: string | null;
}
export interface RideSubmissionData {
name: string;
slug: string;
description?: string | null;
category: string;
ride_sub_type?: string | null;
status: string;
park_id: string;
ride_model_id?: string | null;
manufacturer_id?: string | null;
designer_id?: string | null;
opening_date?: string | null;
closing_date?: string | null;
height_requirement?: number | null;
age_requirement?: number | null;
capacity_per_hour?: number | null;
duration_seconds?: number | null;
max_speed_kmh?: number | null;
max_height_meters?: number | null;
length_meters?: number | null;
drop_height_meters?: number | null;
inversions?: number | null;
max_g_force?: number | null;
coaster_type?: string | null;
seating_type?: string | null;
intensity_level?: string | null;
banner_image_url?: string | null;
banner_image_id?: string | null;
card_image_url?: string | null;
card_image_id?: string | null;
image_url?: string | null;
}
export interface CompanySubmissionData {
name: string;
slug: string;
description?: string | null;
person_type?: 'company' | 'individual';
founded_year?: number | null;
headquarters_location?: string | null;
website_url?: string | null;
logo_url?: string | null;
banner_image_url?: string | null;
banner_image_id?: string | null;
card_image_url?: string | null;
card_image_id?: string | null;
}
export interface RideModelSubmissionData {
name: string;
slug: string;
manufacturer_id: string;
category: string;
ride_type?: string | null;
description?: string | null;
banner_image_url?: string | null;
banner_image_id?: string | null;
card_image_url?: string | null;
card_image_id?: string | null;
}

View File

@@ -4,7 +4,9 @@ export type EntityType =
| 'manufacturer' | 'manufacturer'
| 'operator' | 'operator'
| 'designer' | 'designer'
| 'property_owner'; | 'property_owner'
| 'photo_edit'
| 'photo_delete';
export interface PhotoSubmission { export interface PhotoSubmission {
url: string; url: string;
@@ -52,8 +54,73 @@ export interface UppyPhotoSubmissionUploadProps {
entityId: string; entityId: string;
entityType: EntityType; entityType: EntityType;
parentId?: string; // Optional parent (e.g., parkId for rides) parentId?: string; // Optional parent (e.g., parkId for rides)
}
// Deprecated (kept for backwards compatibility)
parkId?: string; /**
rideId?: string; * Enforces minimal content structure for content_submissions.content
* This type prevents accidental JSON blob storage
*
* RULE: content should ONLY contain action + max 2 reference IDs
*/
export interface ContentSubmissionContent {
action: 'create' | 'edit' | 'delete';
// Only reference IDs allowed - no actual data
[key: string]: string | null | undefined;
}
/**
* Type guard to validate content structure
* Prevents JSON blob violations at runtime
*/
export function isValidSubmissionContent(content: any): content is ContentSubmissionContent {
if (!content || typeof content !== 'object') {
console.error('❌ VIOLATION: content_submissions.content must be an object');
return false;
}
if (!['create', 'edit', 'delete'].includes(content.action)) {
console.error('❌ VIOLATION: content_submissions.content must have valid action:', content.action);
return false;
}
const keys = Object.keys(content);
if (keys.length > 3) {
console.error('❌ VIOLATION: content_submissions.content has too many fields:', keys);
console.error(' Only action + max 2 reference IDs allowed');
return false;
}
// Check for common violations
const forbiddenKeys = ['name', 'description', 'photos', 'data', 'items', 'metadata'];
const violations = keys.filter(k => forbiddenKeys.includes(k));
if (violations.length > 0) {
console.error('❌ VIOLATION: content_submissions.content contains forbidden keys:', violations);
console.error(' These should be in submission_items.item_data instead');
return false;
}
return true;
}
/**
* Helper to create safe submission content
* Use this to ensure compliance with the pattern
*/
export function createSubmissionContent(
action: 'create' | 'edit' | 'delete',
referenceIds: Record<string, string> = {}
): ContentSubmissionContent {
const idKeys = Object.keys(referenceIds);
if (idKeys.length > 2) {
throw new Error(
`Too many reference IDs (${idKeys.length}). Maximum is 2. ` +
`Put additional data in submission_items.item_data instead.`
);
}
return {
action,
...referenceIds
};
} }

View File

@@ -120,6 +120,14 @@ serve(async (req) => {
await approvePhotos(supabase, resolvedData, item.id); await approvePhotos(supabase, resolvedData, item.id);
entityId = item.id; // Use item ID as entity ID for photos entityId = item.id; // Use item ID as entity ID for photos
break; break;
case 'photo_edit':
await editPhoto(supabase, resolvedData);
entityId = resolvedData.photo_id;
break;
case 'photo_delete':
await deletePhoto(supabase, resolvedData);
entityId = resolvedData.photo_id;
break;
default: default:
throw new Error(`Unknown item type: ${item.item_type}`); throw new Error(`Unknown item type: ${item.item_type}`);
} }
@@ -595,3 +603,25 @@ function extractImageId(url: string): string {
const matches = url.match(/\/([^\/]+)\/public$/); const matches = url.match(/\/([^\/]+)\/public$/);
return matches ? matches[1] : url; return matches ? matches[1] : url;
} }
async function editPhoto(supabase: any, data: any): Promise<void> {
console.log(`Editing photo ${data.photo_id}`);
const { error } = await supabase
.from('photos')
.update({
caption: data.new_caption,
})
.eq('id', data.photo_id);
if (error) throw new Error(`Failed to edit photo: ${error.message}`);
}
async function deletePhoto(supabase: any, data: any): Promise<void> {
console.log(`Deleting photo ${data.photo_id}`);
const { error } = await supabase
.from('photos')
.delete()
.eq('id', data.photo_id);
if (error) throw new Error(`Failed to delete photo: ${error.message}`);
}

View File

@@ -0,0 +1,23 @@
-- Restrict direct photo modifications - require moderation queue
-- Drop existing policies that allow direct modification
DROP POLICY IF EXISTS "Moderators can update photos" ON public.photos;
DROP POLICY IF EXISTS "Moderators can delete photos" ON public.photos;
-- Keep read policies
-- Public read access to photos already exists
-- Only service role (edge functions) can modify photos after approval
CREATE POLICY "Service role can insert photos"
ON public.photos FOR INSERT
TO service_role
WITH CHECK (true);
CREATE POLICY "Service role can update photos"
ON public.photos FOR UPDATE
TO service_role
USING (true);
CREATE POLICY "Service role can delete photos"
ON public.photos FOR DELETE
TO service_role
USING (true);

View File

@@ -0,0 +1,10 @@
-- Drop existing foreign key that points to auth.users
ALTER TABLE public.reviews
DROP CONSTRAINT IF EXISTS reviews_user_id_fkey;
-- Add new foreign key constraint pointing to profiles.user_id
ALTER TABLE public.reviews
ADD CONSTRAINT reviews_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES public.profiles(user_id)
ON DELETE CASCADE;

View File

@@ -0,0 +1,19 @@
-- Drop existing constraint
ALTER TABLE public.content_submissions
DROP CONSTRAINT IF EXISTS content_submissions_submission_type_check;
-- Add updated constraint with photo_edit and photo_delete
ALTER TABLE public.content_submissions
ADD CONSTRAINT content_submissions_submission_type_check
CHECK (submission_type IN (
'park',
'ride',
'review',
'photo',
'manufacturer',
'operator',
'designer',
'property_owner',
'photo_edit',
'photo_delete'
));

View File

@@ -0,0 +1,20 @@
-- Drop existing constraint
ALTER TABLE public.submission_items
DROP CONSTRAINT IF EXISTS submission_items_item_type_check;
-- Add updated constraint with photo_edit and photo_delete
ALTER TABLE public.submission_items
ADD CONSTRAINT submission_items_item_type_check
CHECK (item_type IN (
'park',
'ride',
'review',
'photo',
'ride_model',
'manufacturer',
'operator',
'designer',
'property_owner',
'photo_edit',
'photo_delete'
));

View File

@@ -0,0 +1,10 @@
-- Add size constraint to prevent JSON blob storage in content_submissions
-- This enforces the architectural rule: "NEVER STORE JSON IN SQL COLUMNS"
-- Content should only contain: action + max 2 reference IDs (< 500 bytes)
ALTER TABLE public.content_submissions
ADD CONSTRAINT content_size_check
CHECK (pg_column_size(content) < 500);
COMMENT ON CONSTRAINT content_size_check ON public.content_submissions
IS 'Prevents large JSON blobs in content column. Content should only contain action and reference IDs. All actual data goes in submission_items.item_data or specialized relational tables.';

View File

@@ -0,0 +1,23 @@
-- Deprecate legacy JSON columns in rides table
-- These columns violate the architectural rule: "NEVER STORE JSON IN SQL COLUMNS"
-- Use relational tables instead:
-- - ride_technical_specifications (for technical_specs)
-- - ride_coaster_statistics (for coaster_stats)
-- - ride_name_history (for former_names)
-- Mark columns as deprecated with comments
COMMENT ON COLUMN public.rides.technical_specs IS '⚠️ DEPRECATED: Use ride_technical_specifications table instead. This JSON column violates data normalization principles and should not be used for new data.';
COMMENT ON COLUMN public.rides.coaster_stats IS '⚠️ DEPRECATED: Use ride_coaster_statistics table instead. This JSON column violates data normalization principles and should not be used for new data.';
COMMENT ON COLUMN public.rides.former_names IS '⚠️ DEPRECATED: Use ride_name_history table instead. This JSON column violates data normalization principles and should not be used for new data.';
-- Set default to NULL to prevent accidental usage
ALTER TABLE public.rides
ALTER COLUMN technical_specs SET DEFAULT NULL,
ALTER COLUMN coaster_stats SET DEFAULT NULL,
ALTER COLUMN former_names SET DEFAULT NULL;
-- Same for ride_models table
COMMENT ON COLUMN public.ride_models.technical_specs IS '⚠️ DEPRECATED: Use ride_model_technical_specifications table instead. This JSON column violates data normalization principles and should not be used for new data.';
ALTER TABLE public.ride_models
ALTER COLUMN technical_specs SET DEFAULT NULL;

View File

@@ -0,0 +1,19 @@
-- Phase 1: Fix Realtime Subscriptions - Add realtime access helper function
-- Note: Realtime schema permissions are managed by Supabase automatically
-- Tables are already in the supabase_realtime publication with REPLICA IDENTITY FULL
-- Create a function to verify realtime access for moderators
CREATE OR REPLACE FUNCTION public.check_realtime_access()
RETURNS boolean
LANGUAGE plpgsql
STABLE SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Check if user is a moderator (can access realtime moderation features)
RETURN is_moderator(auth.uid());
END;
$$;
-- Add comment to the function
COMMENT ON FUNCTION public.check_realtime_access() IS 'Checks if the current user has permission to access realtime moderation features. Returns true for moderators, admins, and superusers.';

View File

@@ -0,0 +1,30 @@
-- ═══════════════════════════════════════════════════════════════════
-- MIGRATION: Remove JSON Columns from Rides and Ride Models
-- ═══════════════════════════════════════════════════════════════════
--
-- This migration enforces the "NEVER STORE JSON IN SQL COLUMNS" rule
-- by removing deprecated JSON columns that have been replaced with
-- proper relational tables:
--
-- rides.former_names → ride_name_history table
-- rides.coaster_stats → ride_coaster_statistics table
-- rides.technical_specs → ride_technical_specifications table
-- ride_models.technical_specs → ride_model_technical_specifications table
--
-- ═══════════════════════════════════════════════════════════════════
-- Drop deprecated JSON columns from rides table
ALTER TABLE rides
DROP COLUMN IF EXISTS former_names,
DROP COLUMN IF EXISTS coaster_stats,
DROP COLUMN IF EXISTS technical_specs;
-- Drop deprecated JSON column from ride_models table
ALTER TABLE ride_models
DROP COLUMN IF EXISTS technical_specs;
-- Add comments to document the relational approach
COMMENT ON TABLE ride_name_history IS 'Stores historical names for rides - replaces rides.former_names JSON column';
COMMENT ON TABLE ride_coaster_statistics IS 'Stores coaster statistics for rides - replaces rides.coaster_stats JSON column';
COMMENT ON TABLE ride_technical_specifications IS 'Stores technical specifications for rides - replaces rides.technical_specs JSON column';
COMMENT ON TABLE ride_model_technical_specifications IS 'Stores technical specifications for ride models - replaces ride_models.technical_specs JSON column';

View File

@@ -0,0 +1,18 @@
-- ═══════════════════════════════════════════════════════════════════
-- MIGRATION: Remove Legacy items Column from user_top_lists
-- ═══════════════════════════════════════════════════════════════════
--
-- This migration removes the deprecated items jsonb column from
-- user_top_lists table. List items are now stored relationally in
-- the user_top_list_items table.
--
-- SAFETY: Verified that no data exists in the items column and all
-- code references use the relational user_top_list_items table.
-- ═══════════════════════════════════════════════════════════════════
-- Drop the legacy items column
ALTER TABLE user_top_lists
DROP COLUMN IF EXISTS items;
-- Add comment to document the relational approach
COMMENT ON TABLE user_top_list_items IS 'Stores list items relationally - replaces user_top_lists.items JSON column';

View File

@@ -0,0 +1,206 @@
-- ═══════════════════════════════════════════════════════════════════
-- PHASE 3D: Standardize Submission Table Names (Final)
-- ═══════════════════════════════════════════════════════════════════
CREATE TABLE public.ride_submission_coaster_statistics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ride_submission_id UUID NOT NULL,
stat_name TEXT NOT NULL,
stat_value NUMERIC NOT NULL,
unit TEXT,
category TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE public.ride_submission_name_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ride_submission_id UUID NOT NULL,
former_name TEXT NOT NULL,
date_changed DATE,
reason TEXT,
order_index INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE public.ride_submission_technical_specifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ride_submission_id UUID NOT NULL,
spec_name TEXT NOT NULL,
spec_value TEXT NOT NULL,
spec_type TEXT NOT NULL,
unit TEXT,
category TEXT,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Migrate data
INSERT INTO public.ride_submission_coaster_statistics
(id, ride_submission_id, stat_name, stat_value, unit, category, created_at)
SELECT
id, ride_submission_id, stat_name, stat_value, unit, category, created_at
FROM public.ride_coaster_stats;
INSERT INTO public.ride_submission_name_history
(id, ride_submission_id, former_name, date_changed, reason, order_index, created_at)
SELECT
id, ride_submission_id, former_name, date_changed, reason, order_index, created_at
FROM public.ride_former_names;
INSERT INTO public.ride_submission_technical_specifications
(id, ride_submission_id, spec_name, spec_value, spec_type, unit, category, created_at)
SELECT
id, ride_submission_id, spec_name, spec_value, spec_type, unit, category, created_at
FROM public.ride_technical_specs;
-- Create constraints
ALTER TABLE public.ride_submission_coaster_statistics
ADD CONSTRAINT fk_ride_submission_coaster_statistics_ride_submission_id
FOREIGN KEY (ride_submission_id) REFERENCES public.ride_submissions(id) ON DELETE CASCADE;
ALTER TABLE public.ride_submission_name_history
ADD CONSTRAINT fk_ride_submission_name_history_ride_submission_id
FOREIGN KEY (ride_submission_id) REFERENCES public.ride_submissions(id) ON DELETE CASCADE;
ALTER TABLE public.ride_submission_technical_specifications
ADD CONSTRAINT fk_ride_submission_technical_specifications_ride_submission_id
FOREIGN KEY (ride_submission_id) REFERENCES public.ride_submissions(id) ON DELETE CASCADE;
-- Create indexes
CREATE INDEX idx_ride_submission_coaster_statistics_ride_submission_id
ON public.ride_submission_coaster_statistics(ride_submission_id);
CREATE INDEX idx_ride_submission_name_history_ride_submission_id
ON public.ride_submission_name_history(ride_submission_id);
CREATE INDEX idx_ride_submission_technical_specifications_ride_submission_id
ON public.ride_submission_technical_specifications(ride_submission_id);
-- Enable RLS
ALTER TABLE public.ride_submission_coaster_statistics ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ride_submission_name_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.ride_submission_technical_specifications ENABLE ROW LEVEL SECURITY;
-- Create policies
CREATE POLICY "Moderators can manage ride submission coaster statistics"
ON public.ride_submission_coaster_statistics FOR ALL
USING (is_moderator(auth.uid()));
CREATE POLICY "Users can insert stats for their own ride submissions"
ON public.ride_submission_coaster_statistics FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_coaster_statistics.ride_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Users can view stats for their own ride submissions"
ON public.ride_submission_coaster_statistics FOR SELECT
USING (
is_moderator(auth.uid()) OR
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_coaster_statistics.ride_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Moderators can manage ride submission name history"
ON public.ride_submission_name_history FOR ALL
USING (is_moderator(auth.uid()));
CREATE POLICY "Users can insert former names for their own ride submissions"
ON public.ride_submission_name_history FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_name_history.ride_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Users can view former names for their own ride submissions"
ON public.ride_submission_name_history FOR SELECT
USING (
is_moderator(auth.uid()) OR
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_name_history.ride_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Moderators can manage ride submission technical specifications"
ON public.ride_submission_technical_specifications FOR ALL
USING (is_moderator(auth.uid()));
CREATE POLICY "Users can insert specs for their own ride submissions"
ON public.ride_submission_technical_specifications FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_technical_specifications.ride_submission_id
AND cs.user_id = auth.uid()
)
);
CREATE POLICY "Users can view specs for their own ride submissions"
ON public.ride_submission_technical_specifications FOR SELECT
USING (
is_moderator(auth.uid()) OR
EXISTS (
SELECT 1 FROM ride_submissions rs
JOIN content_submissions cs ON cs.id = rs.submission_id
WHERE rs.id = ride_submission_technical_specifications.ride_submission_id
AND cs.user_id = auth.uid()
)
);
-- Verify data integrity
DO $$
DECLARE
old_count INT;
new_count INT;
BEGIN
SELECT COUNT(*) INTO old_count FROM public.ride_coaster_stats;
SELECT COUNT(*) INTO new_count FROM public.ride_submission_coaster_statistics;
IF old_count != new_count THEN
RAISE EXCEPTION 'Coaster stats migration failed: old=%, new=%', old_count, new_count;
END IF;
SELECT COUNT(*) INTO old_count FROM public.ride_former_names;
SELECT COUNT(*) INTO new_count FROM public.ride_submission_name_history;
IF old_count != new_count THEN
RAISE EXCEPTION 'Name history migration failed: old=%, new=%', old_count, new_count;
END IF;
SELECT COUNT(*) INTO old_count FROM public.ride_technical_specs;
SELECT COUNT(*) INTO new_count FROM public.ride_submission_technical_specifications;
IF old_count != new_count THEN
RAISE EXCEPTION 'Technical specs migration failed: old=%, new=%', old_count, new_count;
END IF;
RAISE NOTICE 'Data integrity verified successfully';
END $$;
-- Drop old tables
DROP TABLE public.ride_coaster_stats CASCADE;
DROP TABLE public.ride_former_names CASCADE;
DROP TABLE public.ride_technical_specs CASCADE;
-- Add documentation
COMMENT ON TABLE public.ride_submission_coaster_statistics IS
'Coaster statistics for ride submissions (renamed from ride_coaster_stats)';
COMMENT ON TABLE public.ride_submission_name_history IS
'Name change history for ride submissions (renamed from ride_former_names)';
COMMENT ON TABLE public.ride_submission_technical_specifications IS
'Technical specifications for ride submissions (renamed from ride_technical_specs)';

View File

@@ -0,0 +1,16 @@
-- Add RLS policies for locations table to allow moderators to INSERT and UPDATE
-- This is needed for location creation during park approval
-- Moderators can insert locations during submission approval
CREATE POLICY "Moderators can insert locations"
ON public.locations
FOR INSERT
TO authenticated
WITH CHECK (is_moderator(auth.uid()));
-- Moderators can update locations
CREATE POLICY "Moderators can update locations"
ON public.locations
FOR UPDATE
TO authenticated
USING (is_moderator(auth.uid()));

View File

@@ -0,0 +1,60 @@
-- Configure Realtime access for moderation tables
-- Ensure tables have proper replica identity
ALTER TABLE content_submissions REPLICA IDENTITY FULL;
ALTER TABLE submission_items REPLICA IDENTITY FULL;
ALTER TABLE reports REPLICA IDENTITY FULL;
ALTER TABLE reviews REPLICA IDENTITY FULL;
-- Add tables to realtime publication if not already added
DO $$
BEGIN
-- Add content_submissions
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'content_submissions'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE content_submissions;
END IF;
-- Add submission_items
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'submission_items'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE submission_items;
END IF;
-- Add reports
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'reports'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE reports;
END IF;
-- Add reviews
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'reviews'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE reviews;
END IF;
END $$;
-- Verify configuration
SELECT
schemaname,
tablename,
pubname
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
ORDER BY tablename;

View File

@@ -0,0 +1,60 @@
-- Configure Realtime access for moderation tables
-- Ensure tables have proper replica identity
ALTER TABLE content_submissions REPLICA IDENTITY FULL;
ALTER TABLE submission_items REPLICA IDENTITY FULL;
ALTER TABLE reports REPLICA IDENTITY FULL;
ALTER TABLE reviews REPLICA IDENTITY FULL;
-- Add tables to realtime publication if not already added
DO $$
BEGIN
-- Add content_submissions
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'content_submissions'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE content_submissions;
END IF;
-- Add submission_items
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'submission_items'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE submission_items;
END IF;
-- Add reports
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'reports'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE reports;
END IF;
-- Add reviews
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
AND tablename = 'reviews'
) THEN
ALTER PUBLICATION supabase_realtime ADD TABLE reviews;
END IF;
END $$;
-- Verify configuration
SELECT
schemaname,
tablename,
pubname
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND schemaname = 'public'
ORDER BY tablename;

View File

@@ -0,0 +1,38 @@
-- Drop existing complex SELECT policies that cause Realtime timeouts
DROP POLICY IF EXISTS "Moderators can view all content submissions" ON public.content_submissions;
DROP POLICY IF EXISTS "Users can view their own submissions" ON public.content_submissions;
DROP POLICY IF EXISTS "Moderators can view all submission items" ON public.submission_items;
DROP POLICY IF EXISTS "Users can view their own submission items" ON public.submission_items;
DROP POLICY IF EXISTS "Moderators can view all reports" ON public.reports;
DROP POLICY IF EXISTS "Users can view their own reports" ON public.reports;
DROP POLICY IF EXISTS "Moderators can view all reviews" ON public.reviews;
DROP POLICY IF EXISTS "Users can view their own reviews" ON public.reviews;
DROP POLICY IF EXISTS "Public read access to approved reviews" ON public.reviews;
-- Create simplified SELECT policies for Realtime (app-level authorization handles access control)
CREATE POLICY "Allow authenticated users to view content submissions"
ON public.content_submissions
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Allow authenticated users to view submission items"
ON public.submission_items
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Allow authenticated users to view reports"
ON public.reports
FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Allow authenticated users to view reviews"
ON public.reviews
FOR SELECT
TO authenticated
USING (true);

View File

@@ -0,0 +1,5 @@
-- Enable complete row data for Realtime events
ALTER TABLE public.content_submissions REPLICA IDENTITY FULL;
ALTER TABLE public.submission_items REPLICA IDENTITY FULL;
ALTER TABLE public.reports REPLICA IDENTITY FULL;
ALTER TABLE public.reviews REPLICA IDENTITY FULL;

View File

@@ -0,0 +1,53 @@
-- Add RLS policies to enable Realtime for moderation tables
-- Content Submissions
CREATE POLICY "realtime_admin_access_content_submissions"
ON public.content_submissions
FOR SELECT
TO supabase_realtime_admin
USING (true);
CREATE POLICY "moderators_realtime_content_submissions"
ON public.content_submissions
FOR SELECT
TO authenticated
USING (check_realtime_access());
-- Submission Items
CREATE POLICY "realtime_admin_access_submission_items"
ON public.submission_items
FOR SELECT
TO supabase_realtime_admin
USING (true);
CREATE POLICY "moderators_realtime_submission_items"
ON public.submission_items
FOR SELECT
TO authenticated
USING (check_realtime_access());
-- Reports
CREATE POLICY "realtime_admin_access_reports"
ON public.reports
FOR SELECT
TO supabase_realtime_admin
USING (true);
CREATE POLICY "moderators_realtime_reports"
ON public.reports
FOR SELECT
TO authenticated
USING (check_realtime_access());
-- Reviews
CREATE POLICY "realtime_admin_access_reviews"
ON public.reviews
FOR SELECT
TO supabase_realtime_admin
USING (true);
CREATE POLICY "moderators_realtime_reviews"
ON public.reviews
FOR SELECT
TO authenticated
USING (check_realtime_access());

View File

@@ -0,0 +1,108 @@
-- Create Broadcast Authorization Policy
CREATE POLICY "Moderators can receive broadcasts"
ON "realtime"."messages"
FOR SELECT
TO authenticated
USING (public.check_realtime_access());
-- Create Trigger Functions for Broadcast
CREATE OR REPLACE FUNCTION public.broadcast_content_submission_changes()
RETURNS trigger
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:content_submissions',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_submission_item_changes()
RETURNS trigger
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:submission_items',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_report_changes()
RETURNS trigger
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:reports',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_review_changes()
RETURNS trigger
SECURITY DEFINER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:reviews',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
-- Create Triggers
CREATE TRIGGER broadcast_content_submission_changes
AFTER INSERT OR UPDATE OR DELETE
ON public.content_submissions
FOR EACH ROW
EXECUTE FUNCTION public.broadcast_content_submission_changes();
CREATE TRIGGER broadcast_submission_item_changes
AFTER INSERT OR UPDATE OR DELETE
ON public.submission_items
FOR EACH ROW
EXECUTE FUNCTION public.broadcast_submission_item_changes();
CREATE TRIGGER broadcast_report_changes
AFTER INSERT OR UPDATE OR DELETE
ON public.reports
FOR EACH ROW
EXECUTE FUNCTION public.broadcast_report_changes();
CREATE TRIGGER broadcast_review_changes
AFTER INSERT OR UPDATE OR DELETE
ON public.reviews
FOR EACH ROW
EXECUTE FUNCTION public.broadcast_review_changes();

View File

@@ -0,0 +1,80 @@
-- Fix search_path for broadcast trigger functions
CREATE OR REPLACE FUNCTION public.broadcast_content_submission_changes()
RETURNS trigger
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:content_submissions',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_submission_item_changes()
RETURNS trigger
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:submission_items',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_report_changes()
RETURNS trigger
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:reports',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;
CREATE OR REPLACE FUNCTION public.broadcast_review_changes()
RETURNS trigger
SECURITY DEFINER
SET search_path = public
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM realtime.broadcast_changes(
'moderation:reviews',
TG_OP,
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$;

View File

@@ -0,0 +1,9 @@
-- Grant INSERT permission on realtime.messages for broadcast functionality
-- This allows SECURITY DEFINER trigger functions to insert broadcast messages
GRANT INSERT ON realtime.messages TO postgres;
GRANT INSERT ON realtime.messages TO service_role;
-- Also ensure SELECT is granted for completeness
GRANT SELECT ON realtime.messages TO postgres;
GRANT SELECT ON realtime.messages TO service_role;

View File

@@ -0,0 +1,8 @@
-- Add INSERT RLS policy on realtime.messages for moderators to enable broadcast channels
-- This allows authenticated moderators to send broadcasts on private channels
CREATE POLICY "Moderators can send broadcasts"
ON "realtime"."messages"
FOR INSERT
TO authenticated
WITH CHECK (check_realtime_access());

View File

@@ -0,0 +1,25 @@
-- Create triggers to call broadcast functions for realtime updates
-- Trigger for content_submissions changes
CREATE TRIGGER content_submissions_broadcast
AFTER INSERT OR UPDATE OR DELETE ON public.content_submissions
FOR EACH ROW
EXECUTE FUNCTION broadcast_content_submission_changes();
-- Trigger for submission_items changes
CREATE TRIGGER submission_items_broadcast
AFTER INSERT OR UPDATE OR DELETE ON public.submission_items
FOR EACH ROW
EXECUTE FUNCTION broadcast_submission_item_changes();
-- Trigger for reports changes
CREATE TRIGGER reports_broadcast
AFTER INSERT OR UPDATE OR DELETE ON public.reports
FOR EACH ROW
EXECUTE FUNCTION broadcast_report_changes();
-- Trigger for reviews changes
CREATE TRIGGER reviews_broadcast
AFTER INSERT OR UPDATE OR DELETE ON public.reviews
FOR EACH ROW
EXECUTE FUNCTION broadcast_review_changes();

View File

@@ -0,0 +1,6 @@
-- Add admin settings for panel refresh configuration
INSERT INTO public.admin_settings (setting_key, setting_value, category, description)
VALUES
('system.admin_panel_refresh_mode', '"auto"', 'system', 'Admin panel refresh mode: manual or auto'),
('system.admin_panel_poll_interval', '30', 'system', 'Admin panel auto-refresh interval in seconds (10-300)')
ON CONFLICT (setting_key) DO NOTHING;

View File

@@ -0,0 +1,200 @@
-- =====================================================
-- CRITICAL SECURITY FIXES - Priority 1 & 2
-- =====================================================
-- =====================================================
-- 1. PREVENT PRIVILEGE ESCALATION
-- =====================================================
-- Function to prevent unauthorized superuser role assignment
CREATE OR REPLACE FUNCTION public.prevent_superuser_escalation()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- If trying to grant superuser role
IF NEW.role = 'superuser' THEN
-- Only existing superusers can grant superuser role
IF NOT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can grant the superuser role';
END IF;
END IF;
RETURN NEW;
END;
$$;
-- Apply trigger to user_roles INSERT
DROP TRIGGER IF EXISTS enforce_superuser_escalation_prevention ON public.user_roles;
CREATE TRIGGER enforce_superuser_escalation_prevention
BEFORE INSERT ON public.user_roles
FOR EACH ROW
EXECUTE FUNCTION public.prevent_superuser_escalation();
-- Function to prevent unauthorized modification of superuser roles
CREATE OR REPLACE FUNCTION public.prevent_superuser_role_removal()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- If trying to delete a superuser role
IF OLD.role = 'superuser' THEN
-- Only existing superusers can remove superuser roles
IF NOT EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role = 'superuser'
) THEN
RAISE EXCEPTION 'Only superusers can remove the superuser role';
END IF;
END IF;
RETURN OLD;
END;
$$;
-- Apply trigger to user_roles DELETE
DROP TRIGGER IF EXISTS enforce_superuser_removal_prevention ON public.user_roles;
CREATE TRIGGER enforce_superuser_removal_prevention
BEFORE DELETE ON public.user_roles
FOR EACH ROW
EXECUTE FUNCTION public.prevent_superuser_role_removal();
-- Function to audit all role changes
CREATE OR REPLACE FUNCTION public.audit_role_changes()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO public.admin_audit_log (
admin_user_id,
target_user_id,
action,
details
) VALUES (
auth.uid(),
NEW.user_id,
'role_granted',
jsonb_build_object(
'role', NEW.role,
'timestamp', now()
)
);
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO public.admin_audit_log (
admin_user_id,
target_user_id,
action,
details
) VALUES (
auth.uid(),
OLD.user_id,
'role_revoked',
jsonb_build_object(
'role', OLD.role,
'timestamp', now()
)
);
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
-- Apply trigger to user_roles
DROP TRIGGER IF EXISTS audit_role_changes_trigger ON public.user_roles;
CREATE TRIGGER audit_role_changes_trigger
AFTER INSERT OR DELETE ON public.user_roles
FOR EACH ROW
EXECUTE FUNCTION public.audit_role_changes();
-- =====================================================
-- 2. RESTRICT PUBLIC PROFILE ACCESS
-- =====================================================
-- Remove overly permissive policy
DROP POLICY IF EXISTS "Public can view basic profile info only" ON public.profiles;
-- New policy: Authenticated users can view profiles
CREATE POLICY "Authenticated users can view profiles"
ON public.profiles
FOR SELECT
TO authenticated
USING (
-- Users can view their own profile completely
(auth.uid() = user_id)
OR
-- Moderators can view all profiles
is_moderator(auth.uid())
OR
-- Others can only view public, non-banned profiles
(privacy_level = 'public' AND NOT banned)
);
-- =====================================================
-- 3. SESSION SECURITY ENHANCEMENTS
-- =====================================================
-- Function to hash IP addresses for privacy
CREATE OR REPLACE FUNCTION public.hash_ip_address(ip_text text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
-- Use SHA256 hash with salt
RETURN encode(
digest(ip_text || 'thrillwiki_ip_salt_2025', 'sha256'),
'hex'
);
END;
$$;
-- Add hashed IP column if not exists
ALTER TABLE public.user_sessions
ADD COLUMN IF NOT EXISTS ip_address_hash text;
-- Update existing records (hash current IPs)
UPDATE public.user_sessions
SET ip_address_hash = public.hash_ip_address(host(ip_address)::text)
WHERE ip_address IS NOT NULL AND ip_address_hash IS NULL;
-- Function to clean up expired sessions
CREATE OR REPLACE FUNCTION public.cleanup_expired_sessions()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM public.user_sessions
WHERE expires_at < now();
END;
$$;
-- Allow users to delete their own sessions (for revocation)
DROP POLICY IF EXISTS "Users can delete their own sessions" ON public.user_sessions;
CREATE POLICY "Users can delete their own sessions"
ON public.user_sessions
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Allow users to view their own sessions
DROP POLICY IF EXISTS "Users can view their own sessions" ON public.user_sessions;
CREATE POLICY "Users can view their own sessions"
ON public.user_sessions
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);

View File

@@ -0,0 +1,13 @@
-- Drop the restrictive authenticated-only policy
DROP POLICY IF EXISTS "Authenticated users can view profiles" ON public.profiles;
-- Create a new policy that allows both anonymous and authenticated users to view public profiles
CREATE POLICY "Public can view non-banned public profiles"
ON public.profiles
FOR SELECT
TO anon, authenticated
USING (
(auth.uid() = user_id)
OR is_moderator(auth.uid())
OR ((privacy_level = 'public') AND (NOT banned))
);