mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 14:11:12 -05:00
Merge branch 'main' of https://github.com/pacnpal/thrilltrack-explorer
This commit is contained in:
10
src/App.tsx
10
src/App.tsx
@@ -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 />} />
|
||||||
|
|||||||
254
src/components/admin/LocationSearch.tsx
Normal file
254
src/components/admin/LocationSearch.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { MapPin, Loader2, X } from 'lucide-react';
|
||||||
|
import { ParkLocationMap } from '@/components/maps/ParkLocationMap';
|
||||||
|
|
||||||
|
interface LocationResult {
|
||||||
|
place_id: number;
|
||||||
|
display_name: string;
|
||||||
|
lat: string;
|
||||||
|
lon: string;
|
||||||
|
address: {
|
||||||
|
city?: string;
|
||||||
|
town?: string;
|
||||||
|
village?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
postcode?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedLocation {
|
||||||
|
name: string;
|
||||||
|
city?: string;
|
||||||
|
state_province?: string;
|
||||||
|
country: string;
|
||||||
|
postal_code?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timezone?: string;
|
||||||
|
display_name: string; // Full OSM display name for reference
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationSearchProps {
|
||||||
|
onLocationSelect: (location: SelectedLocation) => void;
|
||||||
|
initialLocationId?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationSearch({ onLocationSelect, initialLocationId, className }: LocationSearchProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [results, setResults] = useState<LocationResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedLocation, setSelectedLocation] = useState<SelectedLocation | null>(null);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 500);
|
||||||
|
|
||||||
|
// Load initial location if editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialLocationId) {
|
||||||
|
loadInitialLocation(initialLocationId);
|
||||||
|
}
|
||||||
|
}, [initialLocationId]);
|
||||||
|
|
||||||
|
const loadInitialLocation = async (locationId: string) => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('locations')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', locationId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (data && !error) {
|
||||||
|
setSelectedLocation({
|
||||||
|
name: data.name,
|
||||||
|
city: data.city || undefined,
|
||||||
|
state_province: data.state_province || undefined,
|
||||||
|
country: data.country,
|
||||||
|
postal_code: data.postal_code || undefined,
|
||||||
|
latitude: parseFloat(data.latitude?.toString() || '0'),
|
||||||
|
longitude: parseFloat(data.longitude?.toString() || '0'),
|
||||||
|
timezone: data.timezone || undefined,
|
||||||
|
display_name: data.name, // Use name as display for existing locations
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchLocations = useCallback(async (query: string) => {
|
||||||
|
if (!query || query.length < 3) {
|
||||||
|
setResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&addressdetails=1&limit=5`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'ThemeParkDatabase/1.0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if response is OK and content-type is JSON
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('OpenStreetMap API error:', response.status);
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
console.error('Invalid response format from OpenStreetMap');
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setResults(data);
|
||||||
|
setShowResults(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching locations:', error);
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedSearch) {
|
||||||
|
searchLocations(debouncedSearch);
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
}, [debouncedSearch, searchLocations]);
|
||||||
|
|
||||||
|
const handleSelectResult = async (result: LocationResult) => {
|
||||||
|
const latitude = parseFloat(result.lat);
|
||||||
|
const longitude = parseFloat(result.lon);
|
||||||
|
|
||||||
|
const city = result.address.city || result.address.town || result.address.village;
|
||||||
|
const locationName = city
|
||||||
|
? `${city}, ${result.address.state || ''} ${result.address.country}`.trim()
|
||||||
|
: result.display_name;
|
||||||
|
|
||||||
|
// Build location data object (no database operations)
|
||||||
|
const locationData: SelectedLocation = {
|
||||||
|
name: locationName,
|
||||||
|
city: city || undefined,
|
||||||
|
state_province: result.address.state || undefined,
|
||||||
|
country: result.address.country || '',
|
||||||
|
postal_code: result.address.postcode || undefined,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
timezone: undefined, // Will be set by server during approval if needed
|
||||||
|
display_name: result.display_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedLocation(locationData);
|
||||||
|
setSearchQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
onLocationSelect(locationData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setSelectedLocation(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setShowResults(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{!selectedLocation ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for a location (city, address, landmark...)"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
{isSearching && (
|
||||||
|
<Loader2 className="absolute right-3 top-3 h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showResults && results.length > 0 && (
|
||||||
|
<Card className="absolute z-50 w-full max-h-64 overflow-y-auto">
|
||||||
|
<div className="divide-y">
|
||||||
|
{results.map((result) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={result.place_id}
|
||||||
|
onClick={() => handleSelectResult(result)}
|
||||||
|
className="w-full text-left p-3 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{result.display_name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{result.lat}, {result.lon}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-3 flex-1">
|
||||||
|
<MapPin className="h-5 w-5 text-primary mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{selectedLocation.name}</p>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1 mt-1">
|
||||||
|
{selectedLocation.city && <p>City: {selectedLocation.city}</p>}
|
||||||
|
{selectedLocation.state_province && <p>State/Province: {selectedLocation.state_province}</p>}
|
||||||
|
<p>Country: {selectedLocation.country}</p>
|
||||||
|
{selectedLocation.postal_code && <p>Postal Code: {selectedLocation.postal_code}</p>}
|
||||||
|
<p className="text-xs">
|
||||||
|
Coordinates: {selectedLocation.latitude.toFixed(6)}, {selectedLocation.longitude.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ParkLocationMap
|
||||||
|
latitude={selectedLocation.latitude}
|
||||||
|
longitude={selectedLocation.longitude}
|
||||||
|
parkName={selectedLocation.name}
|
||||||
|
className="h-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { 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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
218
src/components/moderation/ArrayFieldDiff.tsx
Normal file
218
src/components/moderation/ArrayFieldDiff.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Plus, Minus, Edit, Check } from 'lucide-react';
|
||||||
|
import { formatFieldValue } from '@/lib/submissionChangeDetection';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ArrayFieldDiffProps {
|
||||||
|
fieldName: string;
|
||||||
|
oldArray: any[];
|
||||||
|
newArray: any[];
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArrayDiffItem {
|
||||||
|
type: 'added' | 'removed' | 'modified' | 'unchanged';
|
||||||
|
oldValue?: any;
|
||||||
|
newValue?: any;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArrayFieldDiff({ fieldName, oldArray, newArray, compact = false }: ArrayFieldDiffProps) {
|
||||||
|
const [showUnchanged, setShowUnchanged] = useState(false);
|
||||||
|
|
||||||
|
// Compute array differences
|
||||||
|
const differences = computeArrayDiff(oldArray || [], newArray || []);
|
||||||
|
const changedItems = differences.filter(d => d.type !== 'unchanged');
|
||||||
|
const unchangedCount = differences.filter(d => d.type === 'unchanged').length;
|
||||||
|
const totalChanges = changedItems.length;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
||||||
|
<Edit className="h-3 w-3 mr-1" />
|
||||||
|
{fieldName} ({totalChanges} changes)
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{fieldName} ({differences.length} items, {totalChanges} changed)
|
||||||
|
</div>
|
||||||
|
{unchangedCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowUnchanged(!showUnchanged)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
{showUnchanged ? 'Hide' : 'Show'} {unchangedCount} unchanged
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{differences.map((diff, idx) => {
|
||||||
|
if (diff.type === 'unchanged' && !showUnchanged) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArrayDiffItemDisplay key={idx} diff={diff} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArrayDiffItemDisplay({ diff }: { diff: ArrayDiffItem }) {
|
||||||
|
const isObject = typeof diff.newValue === 'object' || typeof diff.oldValue === 'object';
|
||||||
|
|
||||||
|
switch (diff.type) {
|
||||||
|
case 'added':
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded bg-green-500/10 border border-green-500/20">
|
||||||
|
<Plus className="h-4 w-4 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
{isObject ? (
|
||||||
|
<ObjectDisplay value={diff.newValue} />
|
||||||
|
) : (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{formatFieldValue(diff.newValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'removed':
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded bg-red-500/10 border border-red-500/20">
|
||||||
|
<Minus className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
{isObject ? (
|
||||||
|
<ObjectDisplay value={diff.oldValue} className="line-through opacity-75" />
|
||||||
|
) : (
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatFieldValue(diff.oldValue)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'modified':
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 p-2 rounded bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Edit className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
<div className="text-red-600 dark:text-red-400 line-through mb-1">
|
||||||
|
{isObject ? (
|
||||||
|
<ObjectDisplay value={diff.oldValue} />
|
||||||
|
) : (
|
||||||
|
formatFieldValue(diff.oldValue)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-green-600 dark:text-green-400">
|
||||||
|
{isObject ? (
|
||||||
|
<ObjectDisplay value={diff.newValue} />
|
||||||
|
) : (
|
||||||
|
formatFieldValue(diff.newValue)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'unchanged':
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 p-2 rounded bg-muted/20">
|
||||||
|
<Check className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{isObject ? (
|
||||||
|
<ObjectDisplay value={diff.newValue} />
|
||||||
|
) : (
|
||||||
|
formatFieldValue(diff.newValue)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ObjectDisplay({ value, className = '' }: { value: any; className?: string }) {
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return <span className={className}>{formatFieldValue(value)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-0.5 ${className}`}>
|
||||||
|
{Object.entries(value).map(([key, val]) => (
|
||||||
|
<div key={key} className="flex gap-2">
|
||||||
|
<span className="font-medium capitalize">{key.replace(/_/g, ' ')}:</span>
|
||||||
|
<span>{formatFieldValue(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute differences between two arrays
|
||||||
|
*/
|
||||||
|
function computeArrayDiff(oldArray: any[], newArray: any[]): ArrayDiffItem[] {
|
||||||
|
const results: ArrayDiffItem[] = [];
|
||||||
|
const maxLength = Math.max(oldArray.length, newArray.length);
|
||||||
|
|
||||||
|
// Simple position-based comparison
|
||||||
|
for (let i = 0; i < maxLength; i++) {
|
||||||
|
const oldValue = i < oldArray.length ? oldArray[i] : undefined;
|
||||||
|
const newValue = i < newArray.length ? newArray[i] : undefined;
|
||||||
|
|
||||||
|
if (oldValue === undefined && newValue !== undefined) {
|
||||||
|
// Added
|
||||||
|
results.push({ type: 'added', newValue, index: i });
|
||||||
|
} else if (oldValue !== undefined && newValue === undefined) {
|
||||||
|
// Removed
|
||||||
|
results.push({ type: 'removed', oldValue, index: i });
|
||||||
|
} else if (!isEqual(oldValue, newValue)) {
|
||||||
|
// Modified
|
||||||
|
results.push({ type: 'modified', oldValue, newValue, index: i });
|
||||||
|
} else {
|
||||||
|
// Unchanged
|
||||||
|
results.push({ type: 'unchanged', oldValue, newValue, index: i });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep equality check
|
||||||
|
*/
|
||||||
|
function isEqual(a: any, b: any): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a == null || b == null) return a === b;
|
||||||
|
if (typeof a !== typeof b) return false;
|
||||||
|
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((item, i) => isEqual(item, b[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysA = Object.keys(a);
|
||||||
|
const keysB = Object.keys(b);
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
|
||||||
|
return keysA.every(key => isEqual(a[key], b[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
|||||||
const [cardImageUrl, setCardImageUrl] = useState<string | null>(null);
|
const [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) {
|
||||||
|
|||||||
191
src/components/moderation/FieldComparison.tsx
Normal file
191
src/components/moderation/FieldComparison.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { formatFieldName, formatFieldValue } from '@/lib/submissionChangeDetection';
|
||||||
|
import type { FieldChange, ImageChange } from '@/lib/submissionChangeDetection';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { ArrayFieldDiff } from './ArrayFieldDiff';
|
||||||
|
import { SpecialFieldDisplay } from './SpecialFieldDisplay';
|
||||||
|
|
||||||
|
interface FieldDiffProps {
|
||||||
|
change: FieldChange;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldDiff({ change, compact = false }: FieldDiffProps) {
|
||||||
|
const { field, oldValue, newValue, changeType } = change;
|
||||||
|
|
||||||
|
// Check if this is an array field that needs special handling
|
||||||
|
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
|
||||||
|
return (
|
||||||
|
<ArrayFieldDiff
|
||||||
|
fieldName={formatFieldName(field)}
|
||||||
|
oldArray={oldValue}
|
||||||
|
newArray={newValue}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a special field type that needs custom rendering
|
||||||
|
const specialDisplay = SpecialFieldDisplay({ change, compact });
|
||||||
|
if (specialDisplay) {
|
||||||
|
return specialDisplay;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChangeColor = () => {
|
||||||
|
switch (changeType) {
|
||||||
|
case 'added': return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'removed': return 'text-red-600 dark:text-red-400';
|
||||||
|
case 'modified': return 'text-amber-600 dark:text-amber-400';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={getChangeColor()}>
|
||||||
|
{formatFieldName(field)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium">{formatFieldName(field)}</div>
|
||||||
|
|
||||||
|
{changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
+ {formatFieldValue(newValue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatFieldValue(oldValue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatFieldValue(oldValue)}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{formatFieldValue(newValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageDiffProps {
|
||||||
|
change: ImageChange;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageDiff({ change, compact = false }: ImageDiffProps) {
|
||||||
|
const { type, oldUrl, newUrl } = change;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
||||||
|
{type === 'banner' ? 'Banner' : 'Card'} Image
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine scenario
|
||||||
|
const isAddition = !oldUrl && newUrl;
|
||||||
|
const isRemoval = oldUrl && !newUrl;
|
||||||
|
const isReplacement = oldUrl && newUrl;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{type === 'banner' ? 'Banner' : 'Card'} Image
|
||||||
|
{isAddition && <span className="text-green-600 dark:text-green-400 ml-2">(New)</span>}
|
||||||
|
{isRemoval && <span className="text-red-600 dark:text-red-400 ml-2">(Removed)</span>}
|
||||||
|
{isReplacement && <span className="text-amber-600 dark:text-amber-400 ml-2">(Changed)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{oldUrl && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Before</div>
|
||||||
|
<img
|
||||||
|
src={oldUrl}
|
||||||
|
alt="Previous"
|
||||||
|
className="w-full h-32 object-cover rounded border-2 border-red-500/50"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{oldUrl && newUrl && (
|
||||||
|
<ArrowRight className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newUrl && (
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">{isAddition ? 'New Image' : 'After'}</div>
|
||||||
|
<img
|
||||||
|
src={newUrl}
|
||||||
|
alt="New"
|
||||||
|
className="w-full h-32 object-cover rounded border-2 border-green-500/50"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationDiffProps {
|
||||||
|
oldLocation: any;
|
||||||
|
newLocation: any;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationDiff({ oldLocation, newLocation, compact = false }: LocationDiffProps) {
|
||||||
|
const formatLocation = (loc: any) => {
|
||||||
|
if (!loc) return 'None';
|
||||||
|
if (typeof loc === 'string') return loc;
|
||||||
|
if (typeof loc === 'object') {
|
||||||
|
const parts = [loc.city, loc.state_province, loc.country].filter(Boolean);
|
||||||
|
return parts.join(', ') || 'Unknown';
|
||||||
|
}
|
||||||
|
return String(loc);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
||||||
|
Location
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 p-2 rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium">Location</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{oldLocation && (
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatLocation(oldLocation)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{oldLocation && newLocation && (
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{newLocation && (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{formatLocation(newLocation)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,14 +5,16 @@ import { Edit, MapPin, Zap, Building2, Image, Package } from 'lucide-react';
|
|||||||
import { type SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
import { 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
|
||||||
|
return (
|
||||||
switch (item.item_type) {
|
<SubmissionChangesDisplay
|
||||||
case 'park':
|
item={item}
|
||||||
return (
|
view="detailed"
|
||||||
<div className="space-y-2">
|
showImages={true}
|
||||||
<h4 className="font-semibold">{data.name}</h4>
|
submissionId={submissionId}
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
|
/>
|
||||||
<div className="flex gap-2 flex-wrap">
|
);
|
||||||
{data.park_type && <Badge variant="outline">{data.park_type}</Badge>}
|
|
||||||
{data.status && <Badge variant="outline">{data.status}</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'ride':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-semibold">{data.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{data.category && <Badge variant="outline">{data.category}</Badge>}
|
|
||||||
{data.status && <Badge variant="outline">{data.status}</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'manufacturer':
|
|
||||||
case 'operator':
|
|
||||||
case 'property_owner':
|
|
||||||
case 'designer':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-semibold">{data.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
|
|
||||||
{data.founded_year && (
|
|
||||||
<Badge variant="outline">Founded {data.founded_year}</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'ride_model':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="font-semibold">{data.name}</h4>
|
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">{data.description}</p>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{data.category && <Badge variant="outline">{data.category}</Badge>}
|
|
||||||
{data.ride_type && <Badge variant="outline">{data.ride_type}</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'photo':
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Fetch and display from photo_submission_items */}
|
|
||||||
<PhotoSubmissionDisplay submissionId={data.submission_id} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
No preview available
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 {
|
||||||
setLoading(true);
|
// Only show loading on initial load or filter change
|
||||||
|
if (!silent) {
|
||||||
|
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 {
|
||||||
setLoading(false);
|
// Only clear loading if it was set
|
||||||
|
if (!silent) {
|
||||||
|
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" />
|
||||||
@@ -1254,21 +1202,29 @@ export const ModerationQueue = forwardRef<ModerationQueueRef>((props, ref) => {
|
|||||||
src={photo.url}
|
src={photo.url}
|
||||||
alt={`Photo ${index + 1}: ${photo.filename}`}
|
alt={`Photo ${index + 1}: ${photo.filename}`}
|
||||||
className="w-full max-h-64 object-contain rounded hover:opacity-80 transition-opacity"
|
className="w-full max-h-64 object-contain rounded hover:opacity-80 transition-opacity"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
console.error('Failed to load photo submission:', photo);
|
console.error('Failed to load photo submission:', photo);
|
||||||
const target = e.target as HTMLImageElement;
|
const target = e.target as HTMLImageElement;
|
||||||
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');
|
||||||
onLoad={() => console.log('Photo submission loaded:', photo.url)}
|
urlDisplay.className = 'mt-1 font-mono text-xs break-all px-2';
|
||||||
|
// Use textContent to prevent XSS - it escapes HTML automatically
|
||||||
|
urlDisplay.textContent = photo.url;
|
||||||
|
|
||||||
|
errorContainer.appendChild(errorIcon);
|
||||||
|
errorContainer.appendChild(urlDisplay);
|
||||||
|
parent.appendChild(errorContainer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 hover:opacity-100 transition-opacity rounded">
|
<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>
|
||||||
|
|||||||
159
src/components/moderation/PhotoComparison.tsx
Normal file
159
src/components/moderation/PhotoComparison.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ImageIcon, Trash2, Edit } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PhotoAdditionPreviewProps {
|
||||||
|
photos: Array<{
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
caption?: string;
|
||||||
|
}>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoAdditionPreview({ photos, compact = false }: PhotoAdditionPreviewProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-green-600 dark:text-green-400">
|
||||||
|
<ImageIcon className="h-3 w-3 mr-1" />
|
||||||
|
+{photos.length} Photo{photos.length > 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
<ImageIcon className="h-4 w-4 inline mr-1" />
|
||||||
|
Adding {photos.length} Photo{photos.length > 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{photos.slice(0, 6).map((photo, idx) => (
|
||||||
|
<div key={idx} className="flex flex-col gap-1">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.title || photo.caption || `Photo ${idx + 1}`}
|
||||||
|
className="w-full h-24 object-cover rounded border-2 border-green-500/50"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{(photo.title || photo.caption) && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{photo.title || photo.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{photos.length > 6 && (
|
||||||
|
<div className="flex items-center justify-center h-24 bg-muted rounded border-2 border-dashed">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
+{photos.length - 6} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoEditPreviewProps {
|
||||||
|
photo: {
|
||||||
|
url: string;
|
||||||
|
oldCaption?: string;
|
||||||
|
newCaption?: string;
|
||||||
|
oldTitle?: string;
|
||||||
|
newTitle?: string;
|
||||||
|
};
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoEditPreview({ photo, compact = false }: PhotoEditPreviewProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-amber-600 dark:text-amber-400">
|
||||||
|
<Edit className="h-3 w-3 mr-1" />
|
||||||
|
Photo Edit
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/50">
|
||||||
|
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||||
|
<Edit className="h-4 w-4 inline mr-1" />
|
||||||
|
Photo Metadata Edit
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt="Photo being edited"
|
||||||
|
className="w-32 h-32 object-cover rounded"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-2 text-sm">
|
||||||
|
{photo.oldTitle !== photo.newTitle && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium mb-1">Title:</div>
|
||||||
|
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldTitle || 'None'}</div>
|
||||||
|
<div className="text-green-600 dark:text-green-400">{photo.newTitle || 'None'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{photo.oldCaption !== photo.newCaption && (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium mb-1">Caption:</div>
|
||||||
|
<div className="text-red-600 dark:text-red-400 line-through">{photo.oldCaption || 'None'}</div>
|
||||||
|
<div className="text-green-600 dark:text-green-400">{photo.newCaption || 'None'}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoDeletionPreviewProps {
|
||||||
|
photo: {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
caption?: string;
|
||||||
|
};
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoDeletionPreview({ photo, compact = false }: PhotoDeletionPreviewProps) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-red-600 dark:text-red-400">
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" />
|
||||||
|
Delete Photo
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-destructive/10">
|
||||||
|
<div className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||||
|
<Trash2 className="h-4 w-4 inline mr-1" />
|
||||||
|
Deleting Photo
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.title || photo.caption || 'Photo to be deleted'}
|
||||||
|
className="w-32 h-32 object-cover rounded opacity-75"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(photo.title || photo.caption) && (
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
{photo.title && <div className="font-medium">{photo.title}</div>}
|
||||||
|
{photo.caption && <div className="text-muted-foreground">{photo.caption}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { CheckCircle, XCircle, ExternalLink, Calendar, User, Flag } from 'lucide-react';
|
import { 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 {
|
||||||
setLoading(false);
|
// Only clear loading if it was set
|
||||||
|
if (!silent) {
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal file
327
src/components/moderation/SpecialFieldDisplay.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { MeasurementDisplay } from '@/components/ui/measurement-display';
|
||||||
|
import { SpeedDisplay } from '@/components/ui/speed-display';
|
||||||
|
import { MapPin, ArrowRight, Calendar, ExternalLink } from 'lucide-react';
|
||||||
|
import type { FieldChange } from '@/lib/submissionChangeDetection';
|
||||||
|
import { formatFieldValue } from '@/lib/submissionChangeDetection';
|
||||||
|
|
||||||
|
interface SpecialFieldDisplayProps {
|
||||||
|
change: FieldChange;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpecialFieldDisplay({ change, compact = false }: SpecialFieldDisplayProps) {
|
||||||
|
const fieldName = change.field.toLowerCase();
|
||||||
|
|
||||||
|
// Detect field type
|
||||||
|
if (fieldName.includes('speed') || fieldName === 'max_speed_kmh') {
|
||||||
|
return <SpeedFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName.includes('height') || fieldName.includes('length') ||
|
||||||
|
fieldName === 'max_height_meters' || fieldName === 'length_meters' ||
|
||||||
|
fieldName === 'drop_height_meters') {
|
||||||
|
return <MeasurementFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName === 'status') {
|
||||||
|
return <StatusFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName.includes('date') && !fieldName.includes('updated') && !fieldName.includes('created')) {
|
||||||
|
return <DateFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName.includes('_id') && fieldName !== 'id' && fieldName !== 'user_id') {
|
||||||
|
return <RelationshipFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName === 'latitude' || fieldName === 'longitude') {
|
||||||
|
return <CoordinateFieldDisplay change={change} compact={compact} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to null, will be handled by regular FieldDiff
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeedFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
||||||
|
Speed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
<SpeedDisplay kmh={change.oldValue} />
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<div className="text-green-600 dark:text-green-400">
|
||||||
|
<SpeedDisplay kmh={change.newValue} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
+ <SpeedDisplay kmh={change.newValue} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through">
|
||||||
|
<SpeedDisplay kmh={change.oldValue} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MeasurementFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-purple-600 dark:text-purple-400">
|
||||||
|
Measurement
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
<MeasurementDisplay value={change.oldValue} type="distance" />
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<div className="text-green-600 dark:text-green-400">
|
||||||
|
<MeasurementDisplay value={change.newValue} type="distance" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
+ <MeasurementDisplay value={change.newValue} type="distance" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through">
|
||||||
|
<MeasurementDisplay value={change.oldValue} type="distance" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const statusLower = String(status).toLowerCase();
|
||||||
|
if (statusLower === 'operating' || statusLower === 'active') return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20';
|
||||||
|
if (statusLower === 'closed' || statusLower === 'inactive') return 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20';
|
||||||
|
if (statusLower === 'under_construction' || statusLower === 'pending') return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20';
|
||||||
|
return 'bg-muted/30 text-muted-foreground';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-indigo-600 dark:text-indigo-400">
|
||||||
|
Status
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge className={`${getStatusColor(change.oldValue)} line-through`}>
|
||||||
|
{formatFieldValue(change.oldValue)}
|
||||||
|
</Badge>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<Badge className={getStatusColor(change.newValue)}>
|
||||||
|
{formatFieldValue(change.newValue)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<Badge className={getStatusColor(change.newValue)}>
|
||||||
|
{formatFieldValue(change.newValue)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<Badge className={`${getStatusColor(change.oldValue)} line-through opacity-75`}>
|
||||||
|
{formatFieldValue(change.oldValue)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-teal-600 dark:text-teal-400">
|
||||||
|
<Calendar className="h-3 w-3 mr-1" />
|
||||||
|
Date
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
{formatFieldName(change.field)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatFieldValue(change.oldValue)}
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
{formatFieldValue(change.newValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
+ {formatFieldValue(change.newValue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through">
|
||||||
|
{formatFieldValue(change.oldValue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RelationshipFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
// This would ideally fetch entity names, but for now we show IDs with better formatting
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_id$/, '').replace(/_/g, ' ').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-cyan-600 dark:text-cyan-400">
|
||||||
|
{formatFieldName(change.field)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium">{formatFieldName(change.field)}</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3 text-sm font-mono">
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through text-xs">
|
||||||
|
{String(change.oldValue).slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-green-600 dark:text-green-400 text-xs">
|
||||||
|
{String(change.newValue).slice(0, 8)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400 font-mono text-xs">
|
||||||
|
+ {String(change.newValue).slice(0, 8)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono text-xs">
|
||||||
|
{String(change.oldValue).slice(0, 8)}...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CoordinateFieldDisplay({ change, compact }: { change: FieldChange; compact: boolean }) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="text-orange-600 dark:text-orange-400">
|
||||||
|
<MapPin className="h-3 w-3 mr-1" />
|
||||||
|
Coordinates
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFieldName = (name: string) =>
|
||||||
|
name.replace(/_/g, ' ').replace(/([A-Z])/g, ' $1').trim()
|
||||||
|
.replace(/^./, str => str.toUpperCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 p-3 rounded-md bg-muted/30 border">
|
||||||
|
<div className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
{formatFieldName(change.field)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{change.changeType === 'modified' && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-red-600 dark:text-red-400 line-through font-mono">
|
||||||
|
{Number(change.oldValue).toFixed(6)}°
|
||||||
|
</span>
|
||||||
|
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-green-600 dark:text-green-400 font-mono">
|
||||||
|
{Number(change.newValue).toFixed(6)}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'added' && (
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400 font-mono">
|
||||||
|
+ {Number(change.newValue).toFixed(6)}°
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{change.changeType === 'removed' && (
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 line-through font-mono">
|
||||||
|
{Number(change.oldValue).toFixed(6)}°
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal file
230
src/components/moderation/SubmissionChangesDisplay.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { FieldDiff, ImageDiff, LocationDiff } from './FieldComparison';
|
||||||
|
import { PhotoAdditionPreview, PhotoEditPreview, PhotoDeletionPreview } from './PhotoComparison';
|
||||||
|
import { detectChanges, type ChangesSummary } from '@/lib/submissionChangeDetection';
|
||||||
|
import type { SubmissionItemData } from '@/types/submissions';
|
||||||
|
import type { SubmissionItemWithDeps } from '@/lib/submissionItemsService';
|
||||||
|
import { Building2, Train, MapPin, Building, User, ImageIcon, Trash2, Edit, Plus, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SubmissionChangesDisplayProps {
|
||||||
|
item: SubmissionItemData | SubmissionItemWithDeps;
|
||||||
|
view?: 'summary' | 'detailed';
|
||||||
|
showImages?: boolean;
|
||||||
|
submissionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to determine change magnitude
|
||||||
|
function getChangeMagnitude(totalChanges: number, hasImages: boolean, action: string) {
|
||||||
|
if (action === 'delete') return { label: 'Deletion', variant: 'destructive' as const, icon: AlertTriangle };
|
||||||
|
if (action === 'create') return { label: 'New', variant: 'default' as const, icon: Plus };
|
||||||
|
if (hasImages) return { label: 'Major', variant: 'default' as const, icon: Edit };
|
||||||
|
if (totalChanges >= 5) return { label: 'Major', variant: 'default' as const, icon: Edit };
|
||||||
|
if (totalChanges >= 3) return { label: 'Moderate', variant: 'secondary' as const, icon: Edit };
|
||||||
|
return { label: 'Minor', variant: 'outline' as const, icon: Edit };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionChangesDisplay({
|
||||||
|
item,
|
||||||
|
view = 'summary',
|
||||||
|
showImages = true,
|
||||||
|
submissionId
|
||||||
|
}: SubmissionChangesDisplayProps) {
|
||||||
|
const [changes, setChanges] = useState<ChangesSummary | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadChanges = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const detectedChanges = await detectChanges(item, submissionId);
|
||||||
|
setChanges(detectedChanges);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
loadChanges();
|
||||||
|
}, [item, submissionId]);
|
||||||
|
|
||||||
|
if (loading || !changes) {
|
||||||
|
return <Skeleton className="h-16 w-full" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get appropriate icon for entity type
|
||||||
|
const getEntityIcon = () => {
|
||||||
|
const iconClass = "h-4 w-4";
|
||||||
|
switch (item.item_type) {
|
||||||
|
case 'park': return <Building2 className={iconClass} />;
|
||||||
|
case 'ride': return <Train className={iconClass} />;
|
||||||
|
case 'manufacturer':
|
||||||
|
case 'operator':
|
||||||
|
case 'property_owner':
|
||||||
|
case 'designer': return <Building className={iconClass} />;
|
||||||
|
case 'photo': return <ImageIcon className={iconClass} />;
|
||||||
|
default: return <MapPin className={iconClass} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get action badge
|
||||||
|
const getActionBadge = () => {
|
||||||
|
switch (changes.action) {
|
||||||
|
case 'create':
|
||||||
|
return <Badge className="bg-green-600"><Plus className="h-3 w-3 mr-1" />New</Badge>;
|
||||||
|
case 'edit':
|
||||||
|
return <Badge className="bg-amber-600"><Edit className="h-3 w-3 mr-1" />Edit</Badge>;
|
||||||
|
case 'delete':
|
||||||
|
return <Badge variant="destructive"><Trash2 className="h-3 w-3 mr-1" />Delete</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const magnitude = getChangeMagnitude(
|
||||||
|
changes.totalChanges,
|
||||||
|
changes.imageChanges.length > 0,
|
||||||
|
changes.action
|
||||||
|
);
|
||||||
|
|
||||||
|
if (view === 'summary') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{getEntityIcon()}
|
||||||
|
<span className="font-medium">{changes.entityName}</span>
|
||||||
|
{getActionBadge()}
|
||||||
|
{changes.action === 'edit' && (
|
||||||
|
<Badge variant={magnitude.variant} className="text-xs">
|
||||||
|
{magnitude.label} Change
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changes.action === 'edit' && changes.totalChanges > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{changes.fieldChanges.slice(0, 5).map((change, idx) => (
|
||||||
|
<FieldDiff key={idx} change={change} compact />
|
||||||
|
))}
|
||||||
|
{changes.imageChanges.map((change, idx) => (
|
||||||
|
<ImageDiff key={`img-${idx}`} change={change} compact />
|
||||||
|
))}
|
||||||
|
{changes.photoChanges.map((change, idx) => {
|
||||||
|
if (change.type === 'added' && change.photos) {
|
||||||
|
return <PhotoAdditionPreview key={`photo-${idx}`} photos={change.photos} compact />;
|
||||||
|
}
|
||||||
|
if (change.type === 'edited' && change.photo) {
|
||||||
|
return <PhotoEditPreview key={`photo-${idx}`} photo={change.photo} compact />;
|
||||||
|
}
|
||||||
|
if (change.type === 'deleted' && change.photo) {
|
||||||
|
return <PhotoDeletionPreview key={`photo-${idx}`} photo={change.photo} compact />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
{changes.hasLocationChange && (
|
||||||
|
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
|
||||||
|
Location
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{changes.totalChanges > 5 && (
|
||||||
|
<Badge variant="outline">
|
||||||
|
+{changes.totalChanges - 5} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.action === 'create' && item.item_data?.description && (
|
||||||
|
<div className="text-sm text-muted-foreground line-clamp-2">
|
||||||
|
{item.item_data.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.action === 'delete' && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
Marked for deletion
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detailed view
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{getEntityIcon()}
|
||||||
|
<h3 className="text-lg font-semibold">{changes.entityName}</h3>
|
||||||
|
{getActionBadge()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{changes.action === 'create' && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Creating new {item.item_type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.action === 'delete' && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
This {item.item_type} will be deleted
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.action === 'edit' && changes.totalChanges > 0 && (
|
||||||
|
<>
|
||||||
|
{changes.fieldChanges.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-medium">Field Changes ({changes.fieldChanges.length})</h4>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{changes.fieldChanges.map((change, idx) => (
|
||||||
|
<FieldDiff key={idx} change={change} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImages && changes.imageChanges.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-medium">Image Changes</h4>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{changes.imageChanges.map((change, idx) => (
|
||||||
|
<ImageDiff key={idx} change={change} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showImages && changes.photoChanges.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-medium">Photo Changes</h4>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{changes.photoChanges.map((change, idx) => {
|
||||||
|
if (change.type === 'added' && change.photos) {
|
||||||
|
return <PhotoAdditionPreview key={idx} photos={change.photos} compact={false} />;
|
||||||
|
}
|
||||||
|
if (change.type === 'edited' && change.photo) {
|
||||||
|
return <PhotoEditPreview key={idx} photo={change.photo} compact={false} />;
|
||||||
|
}
|
||||||
|
if (change.type === 'deleted' && change.photo) {
|
||||||
|
return <PhotoDeletionPreview key={idx} photo={change.photo} compact={false} />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.hasLocationChange && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h4 className="text-sm font-medium">Location Change</h4>
|
||||||
|
<LocationDiff
|
||||||
|
oldLocation={item.original_data?.location || item.original_data?.location_id}
|
||||||
|
newLocation={item.item_data?.location || item.item_data?.location_id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{changes.action === 'edit' && changes.totalChanges === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No changes detected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/moderation/SubmissionItemsList.tsx
Normal file
112
src/components/moderation/SubmissionItemsList.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { SubmissionChangesDisplay } from './SubmissionChangesDisplay';
|
||||||
|
import { PhotoSubmissionDisplay } from './PhotoSubmissionDisplay';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import type { SubmissionItemData } from '@/types/submissions';
|
||||||
|
|
||||||
|
interface SubmissionItemsListProps {
|
||||||
|
submissionId: string;
|
||||||
|
view?: 'summary' | 'detailed';
|
||||||
|
showImages?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SubmissionItemsList({
|
||||||
|
submissionId,
|
||||||
|
view = 'summary',
|
||||||
|
showImages = true
|
||||||
|
}: SubmissionItemsListProps) {
|
||||||
|
const [items, setItems] = useState<SubmissionItemData[]>([]);
|
||||||
|
const [hasPhotos, setHasPhotos] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSubmissionItems();
|
||||||
|
}, [submissionId]);
|
||||||
|
|
||||||
|
const fetchSubmissionItems = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Fetch submission items
|
||||||
|
const { data: itemsData, error: itemsError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.select('*')
|
||||||
|
.eq('submission_id', submissionId)
|
||||||
|
.order('order_index');
|
||||||
|
|
||||||
|
if (itemsError) throw itemsError;
|
||||||
|
|
||||||
|
// Check for photo submissions (using array query to avoid 406)
|
||||||
|
const { data: photoData, error: photoError } = await supabase
|
||||||
|
.from('photo_submissions')
|
||||||
|
.select('id')
|
||||||
|
.eq('submission_id', submissionId);
|
||||||
|
|
||||||
|
if (photoError) {
|
||||||
|
console.warn('Error checking photo submissions:', photoError);
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((itemsData || []) as SubmissionItemData[]);
|
||||||
|
setHasPhotos(photoData && photoData.length > 0);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching submission items:', err);
|
||||||
|
setError('Failed to load submission details');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
{view === 'detailed' && <Skeleton className="h-32 w-full" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0 && !hasPhotos) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No items found for this submission
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Show regular submission items */}
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||||
|
<SubmissionChangesDisplay
|
||||||
|
item={item}
|
||||||
|
view={view}
|
||||||
|
showImages={showImages}
|
||||||
|
submissionId={submissionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Show photo submission if exists */}
|
||||||
|
{hasPhotos && (
|
||||||
|
<div className={view === 'summary' ? 'border-l-2 border-primary/20 pl-3' : ''}>
|
||||||
|
<PhotoSubmissionDisplay submissionId={submissionId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useToast } from '@/hooks/use-toast';
|
import { 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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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`}>
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
src/components/rides/RideModelCard.tsx
Normal file
84
src/components/rides/RideModelCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FerrisWheel } from 'lucide-react';
|
||||||
|
import { RideModel } from '@/types/database';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface RideModelCardProps {
|
||||||
|
model: RideModel;
|
||||||
|
manufacturerSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RideModelCard({ model, manufacturerSlug }: RideModelCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const formatCategory = (category: string) => {
|
||||||
|
return category.split('_').map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRideType = (type: string) => {
|
||||||
|
return type.split('_').map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const rideCount = (model as any).rides?.[0]?.count || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden hover:shadow-lg transition-shadow cursor-pointer group">
|
||||||
|
<div
|
||||||
|
className="aspect-video bg-gradient-to-br from-primary/10 via-secondary/10 to-accent/10 relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{((model as any).card_image_url || (model as any).card_image_id) ? (
|
||||||
|
<img
|
||||||
|
src={(model as any).card_image_url || `https://imagedelivery.net/X-2-mmiWukWxvAQQ2_o-7Q/${(model as any).card_image_id}/public`}
|
||||||
|
alt={model.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<FerrisWheel className="w-16 h-16 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-lg line-clamp-1 group-hover:text-primary transition-colors">
|
||||||
|
{model.name}
|
||||||
|
</h3>
|
||||||
|
{model.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||||
|
{model.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{formatCategory(model.category)}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{formatRideType(model.ride_type)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{rideCount} {rideCount === 1 ? 'ride' : 'rides'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/manufacturers/${manufacturerSlug}/rides?model=${model.slug}`)}
|
||||||
|
>
|
||||||
|
View Rides
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/**
|
|
||||||
* @deprecated Use EntityPhotoGallery directly or import from RidePhotoGalleryWrapper
|
|
||||||
* This file is kept for backwards compatibility
|
|
||||||
*/
|
|
||||||
export { RidePhotoGallery } from './RidePhotoGalleryWrapper';
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { EntityPhotoGallery } from '@/components/upload/EntityPhotoGallery';
|
|
||||||
|
|
||||||
interface RidePhotoGalleryProps {
|
|
||||||
rideId: string;
|
|
||||||
rideName: string;
|
|
||||||
parkId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Backwards-compatible wrapper for RidePhotoGallery
|
|
||||||
* Uses the generic EntityPhotoGallery component internally
|
|
||||||
*/
|
|
||||||
export function RidePhotoGallery({ rideId, rideName, parkId }: RidePhotoGalleryProps) {
|
|
||||||
return (
|
|
||||||
<EntityPhotoGallery
|
|
||||||
entityId={rideId}
|
|
||||||
entityType="ride"
|
|
||||||
entityName={rideName}
|
|
||||||
parentId={parkId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,51 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { 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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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':
|
||||||
navigate(`/rides/${searchResult.id}`);
|
const parkSlug = (searchResult.data as any).park?.slug;
|
||||||
|
const rideSlug = searchResult.slug;
|
||||||
|
if (parkSlug && rideSlug) {
|
||||||
|
navigate(`/parks/${parkSlug}/rides/${rideSlug}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/rides/${searchResult.id}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'company':
|
case 'company':
|
||||||
navigate(`/companies/${searchResult.id}`);
|
const companyType = (searchResult.data as any).company_type;
|
||||||
|
const companySlug = searchResult.slug;
|
||||||
|
if (companyType && companySlug) {
|
||||||
|
switch (companyType) {
|
||||||
|
case 'operator':
|
||||||
|
navigate(`/operators/${companySlug}`);
|
||||||
|
break;
|
||||||
|
case 'property_owner':
|
||||||
|
navigate(`/owners/${companySlug}`);
|
||||||
|
break;
|
||||||
|
case 'manufacturer':
|
||||||
|
navigate(`/manufacturers/${companySlug}`);
|
||||||
|
break;
|
||||||
|
case 'designer':
|
||||||
|
navigate(`/designers/${companySlug}`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
navigate(`/companies/${searchResult.id}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
navigate(`/companies/${searchResult.id}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
123
src/components/settings/SessionsTab.tsx
Normal file
123
src/components/settings/SessionsTab.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { Monitor, Smartphone, Tablet, Trash2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface UserSession {
|
||||||
|
id: string;
|
||||||
|
device_info: any;
|
||||||
|
last_activity: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
session_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsTab() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchSessions = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('user_sessions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('last_activity', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error fetching sessions:', error);
|
||||||
|
} else {
|
||||||
|
setSessions(data || []);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const revokeSession = async (sessionId: string) => {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('user_sessions')
|
||||||
|
.delete()
|
||||||
|
.eq('id', sessionId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to revoke session',
|
||||||
|
variant: 'destructive'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Session revoked successfully'
|
||||||
|
});
|
||||||
|
fetchSessions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDeviceIcon = (deviceInfo: any) => {
|
||||||
|
const ua = deviceInfo?.userAgent?.toLowerCase() || '';
|
||||||
|
if (ua.includes('mobile')) return <Smartphone className="w-4 h-4" />;
|
||||||
|
if (ua.includes('tablet')) return <Tablet className="w-4 h-4" />;
|
||||||
|
return <Monitor className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-muted-foreground">Loading sessions...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Active Sessions</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage your active login sessions across devices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<Card key={session.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{getDeviceIcon(session.device_info)}
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{session.device_info?.browser || 'Unknown Browser'}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Last active: {format(new Date(session.last_activity), 'PPpp')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Expires: {format(new Date(session.expires_at), 'PPpp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revokeSession(session.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<Card className="p-8 text-center text-muted-foreground">
|
||||||
|
No active sessions found
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { 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 (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
if (submissionError) throw submissionError;
|
||||||
|
|
||||||
|
// Create submission item
|
||||||
|
const { error: itemError } = await supabase
|
||||||
|
.from('submission_items')
|
||||||
|
.insert({
|
||||||
|
submission_id: submission.id,
|
||||||
|
item_type: 'photo_delete',
|
||||||
|
item_data: {
|
||||||
|
photo_id: photoToDelete.id,
|
||||||
|
entity_type: entityType,
|
||||||
|
entity_id: entityId,
|
||||||
|
cloudflare_image_url: photoToDelete.cloudflare_image_url,
|
||||||
|
caption: photoToDelete.caption,
|
||||||
|
reason: deleteReason
|
||||||
|
},
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
await fetchPhotos();
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Success',
|
title: 'Delete request submitted',
|
||||||
description: 'Photo deleted',
|
description: 'Your photo deletion request has been submitted for moderation',
|
||||||
});
|
});
|
||||||
onUpdate?.();
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
/**
|
|
||||||
* @deprecated This component is deprecated. Use UppyPhotoSubmissionUpload instead.
|
|
||||||
* This file is kept for backwards compatibility only.
|
|
||||||
*
|
|
||||||
* For new implementations, use:
|
|
||||||
* - UppyPhotoSubmissionUpload for direct uploads
|
|
||||||
* - EntityPhotoGallery for entity-specific photo galleries
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Upload, X, Camera } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { useToast } from '@/hooks/use-toast';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
|
|
||||||
interface PhotoSubmissionUploadProps {
|
|
||||||
onSubmissionComplete?: () => void;
|
|
||||||
parkId?: string;
|
|
||||||
rideId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PhotoSubmissionUpload({ onSubmissionComplete, parkId, rideId }: PhotoSubmissionUploadProps) {
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [caption, setCaption] = useState('');
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
|
||||||
|
|
||||||
if (imageFiles.length !== files.length) {
|
|
||||||
toast({
|
|
||||||
title: "Invalid Files",
|
|
||||||
description: "Only image files are allowed",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedFiles(prev => [...prev, ...imageFiles].slice(0, 5)); // Max 5 files
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFileToCloudflare = async (file: File): Promise<{ id: string; url: string }> => {
|
|
||||||
try {
|
|
||||||
// Get upload URL from Supabase edge function
|
|
||||||
const { data: uploadData, error: uploadError } = await supabase.functions.invoke('upload-image', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadError || !uploadData?.uploadURL) {
|
|
||||||
console.error('Failed to get upload URL:', uploadError);
|
|
||||||
throw new Error('Failed to get upload URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload file directly to Cloudflare
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const uploadResponse = await fetch(uploadData.uploadURL, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResponse.ok) {
|
|
||||||
const errorText = await uploadResponse.text();
|
|
||||||
console.error('Cloudflare upload failed:', errorText);
|
|
||||||
throw new Error('Failed to upload file to Cloudflare');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await uploadResponse.json();
|
|
||||||
const imageId = result.result?.id;
|
|
||||||
|
|
||||||
if (!imageId) {
|
|
||||||
console.error('No image ID returned from Cloudflare:', result);
|
|
||||||
throw new Error('Invalid response from Cloudflare');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the delivery URL using the edge function with URL parameters
|
|
||||||
const getImageUrl = new URL(`https://ydvtmnrszybqnbcqbdcy.supabase.co/functions/v1/upload-image`);
|
|
||||||
getImageUrl.searchParams.set('id', imageId);
|
|
||||||
|
|
||||||
const response = await fetch(getImageUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4`,
|
|
||||||
'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('Failed to get image status:', errorText);
|
|
||||||
throw new Error('Failed to get image URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusData = await response.json();
|
|
||||||
|
|
||||||
if (!statusData?.urls?.public) {
|
|
||||||
console.error('No image URL returned:', statusData);
|
|
||||||
throw new Error('No image URL available');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: imageId,
|
|
||||||
url: statusData.urls.public,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!user) {
|
|
||||||
toast({
|
|
||||||
title: "Authentication Required",
|
|
||||||
description: "Please log in to submit photos",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedFiles.length === 0) {
|
|
||||||
toast({
|
|
||||||
title: "No Files Selected",
|
|
||||||
description: "Please select at least one image to submit",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
// Upload files to Cloudflare Images first
|
|
||||||
const photoSubmissions = await Promise.all(
|
|
||||||
selectedFiles.map(async (file, index) => {
|
|
||||||
const uploadResult = await uploadFileToCloudflare(file);
|
|
||||||
|
|
||||||
return {
|
|
||||||
filename: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
url: uploadResult.url,
|
|
||||||
imageId: uploadResult.id,
|
|
||||||
caption: index === 0 ? caption : '', // Only first image gets the caption
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Submit to content_submissions table
|
|
||||||
const { error } = await supabase
|
|
||||||
.from('content_submissions')
|
|
||||||
.insert({
|
|
||||||
user_id: user.id,
|
|
||||||
submission_type: 'photo',
|
|
||||||
content: {
|
|
||||||
photos: photoSubmissions,
|
|
||||||
title: title.trim() || undefined,
|
|
||||||
caption: caption.trim() || undefined,
|
|
||||||
park_id: parkId,
|
|
||||||
ride_id: rideId,
|
|
||||||
context: parkId ? 'park' : rideId ? 'ride' : 'general',
|
|
||||||
},
|
|
||||||
status: 'pending',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "Photos Submitted",
|
|
||||||
description: "Your photos have been submitted for moderation review",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setSelectedFiles([]);
|
|
||||||
setCaption('');
|
|
||||||
setTitle('');
|
|
||||||
onSubmissionComplete?.();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error submitting photos:', error);
|
|
||||||
toast({
|
|
||||||
title: "Submission Failed",
|
|
||||||
description: "Failed to submit photos. Please try again.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 space-y-4">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<Camera className="w-5 h-5" />
|
|
||||||
<h3 className="text-lg font-semibold">Submit Photos</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="photo-title">Title (optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="photo-title"
|
|
||||||
placeholder="Give your photo submission a title"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="photo-caption">Caption (optional)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="photo-caption"
|
|
||||||
placeholder="Add a caption to describe your photos"
|
|
||||||
value={caption}
|
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="photo-upload">Select Photos</Label>
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
id="photo-upload"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => document.getElementById('photo-upload')?.click()}
|
|
||||||
className="w-full"
|
|
||||||
disabled={selectedFiles.length >= 5}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Choose Photos {selectedFiles.length > 0 && `(${selectedFiles.length}/5)`}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedFiles.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Label>Selected Photos:</Label>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{selectedFiles.map((file, index) => (
|
|
||||||
<div key={index} className="relative">
|
|
||||||
<img
|
|
||||||
src={URL.createObjectURL(file)}
|
|
||||||
alt={`Preview ${index + 1}`}
|
|
||||||
className="w-full h-24 object-cover rounded border"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
|
|
||||||
onClick={() => removeFile(index)}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={uploading || selectedFiles.length === 0}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{uploading ? 'Submitting...' : 'Submit Photos for Review'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -281,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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
113
src/hooks/useModerationStats.ts
Normal file
113
src/hooks/useModerationStats.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
328
src/lib/submissionChangeDetection.ts
Normal file
328
src/lib/submissionChangeDetection.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
|||||||
195
src/lib/units.ts
195
src/lib/units.ts
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,42 +80,62 @@ 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">
|
||||||
<Card>
|
{/* Refresh status indicator */}
|
||||||
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
|
<div className="flex items-center justify-between">
|
||||||
<FileText className="h-4 w-4 text-muted-foreground mb-2" />
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<CardTitle className="text-sm font-medium">Pending Submissions</CardTitle>
|
<RefreshCw className="w-4 h-4" />
|
||||||
</CardHeader>
|
{refreshMode === 'auto' ? (
|
||||||
<CardContent className="text-center">
|
<span>Auto-refresh: every {pollInterval / 1000}s</span>
|
||||||
<div className="text-2xl font-bold">
|
) : (
|
||||||
{realtimeStats.pendingSubmissions}
|
<span>Manual refresh only</span>
|
||||||
</div>
|
)}
|
||||||
</CardContent>
|
{lastUpdated && (
|
||||||
</Card>
|
<span className="text-xs">
|
||||||
|
• Last updated: {lastUpdated.toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
{/* Stats cards */}
|
||||||
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
|
<div className="grid grid-cols-3 gap-3 md:gap-6">
|
||||||
<Flag className="h-4 w-4 text-muted-foreground mb-2" />
|
<Card>
|
||||||
<CardTitle className="text-sm font-medium">Open Reports</CardTitle>
|
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
|
||||||
</CardHeader>
|
<FileText className="h-4 w-4 text-muted-foreground mb-2" />
|
||||||
<CardContent className="text-center">
|
<CardTitle className="text-sm font-medium">Pending Submissions</CardTitle>
|
||||||
<div className="text-2xl font-bold">
|
</CardHeader>
|
||||||
{realtimeStats.openReports}
|
<CardContent className="text-center">
|
||||||
</div>
|
<div className="text-2xl font-bold">
|
||||||
</CardContent>
|
{stats.pendingSubmissions}
|
||||||
</Card>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<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">
|
||||||
<AlertCircle className="h-4 w-4 text-muted-foreground mb-2" />
|
<Flag className="h-4 w-4 text-muted-foreground mb-2" />
|
||||||
<CardTitle className="text-sm font-medium">Flagged Content</CardTitle>
|
<CardTitle className="text-sm font-medium">Open Reports</CardTitle>
|
||||||
</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.openReports}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col items-center justify-center space-y-0 pb-2 text-center">
|
||||||
|
<AlertCircle className="h-4 w-4 text-muted-foreground mb-2" />
|
||||||
|
<CardTitle className="text-sm font-medium">Flagged Content</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{stats.flaggedContent}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content Moderation Section */}
|
{/* Content Moderation Section */}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') ||
|
||||||
|
|||||||
@@ -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
252
src/pages/DesignerRides.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
212
src/pages/ManufacturerModels.tsx
Normal file
212
src/pages/ManufacturerModels.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
src/pages/ManufacturerRides.tsx
Normal file
252
src/pages/ManufacturerRides.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
278
src/pages/OperatorParks.tsx
Normal 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
278
src/pages/OwnerParks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -158,7 +129,7 @@ export default function ParkDetail() {
|
|||||||
toast({
|
toast({
|
||||||
title: "Submission Failed",
|
title: "Submission Failed",
|
||||||
description: error.message || "Failed to submit ride for review.",
|
description: error.message || "Failed to submit ride for review.",
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -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,16 +543,27 @@ 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">
|
<>
|
||||||
{rides.map(ride => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<RideCard
|
{rides.map(ride => (
|
||||||
key={ride.id}
|
<RideCard
|
||||||
ride={ride}
|
key={ride.id}
|
||||||
showParkName={false}
|
ride={ride}
|
||||||
parkSlug={park.slug}
|
showParkName={false}
|
||||||
/>
|
parkSlug={park.slug}
|
||||||
))}
|
/>
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
85
src/types/submission-data.ts
Normal file
85
src/types/submission-data.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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'
|
||||||
|
));
|
||||||
@@ -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'
|
||||||
|
));
|
||||||
@@ -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.';
|
||||||
@@ -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;
|
||||||
@@ -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.';
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
@@ -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)';
|
||||||
@@ -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()));
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user