mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 20:31:12 -05:00
Improve component stability and user experience with safety checks
Implement robust error handling, safety checks for data structures, and state management improvements across various components to prevent runtime errors and enhance user experience. Replit-Commit-Author: Agent Replit-Commit-Session-Id: a71e826a-1d38-4b6e-a34f-fbf5ba1f1b25 Replit-Commit-Checkpoint-Type: intermediate_checkpoint
This commit is contained in:
@@ -26,6 +26,9 @@ const eventTypeConfig: Record<HistoryEventType, { icon: typeof Tag; color: strin
|
||||
milestone: { icon: Milestone, color: 'text-pink-500', label: 'Milestone' },
|
||||
};
|
||||
|
||||
// Fallback config for unknown event types
|
||||
const defaultEventConfig = { icon: Tag, color: 'text-gray-500', label: 'Event' };
|
||||
|
||||
export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
@@ -54,7 +57,10 @@ export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimel
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
{sortedEvents.map((event, index) => {
|
||||
const config = eventTypeConfig[event.type];
|
||||
// Safety check: verify event.type exists in eventTypeConfig, use fallback if not
|
||||
const config = event.type && eventTypeConfig[event.type]
|
||||
? eventTypeConfig[event.type]
|
||||
: defaultEventConfig;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
@@ -105,14 +111,25 @@ export function EntityHistoryTimeline({ events, entityName }: EntityHistoryTimel
|
||||
}
|
||||
|
||||
function formatEventDate(dateString: string): string {
|
||||
// Safety check: validate dateString exists and is a string
|
||||
if (!dateString || typeof dateString !== 'string') {
|
||||
return 'Unknown date';
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle year-only dates
|
||||
if (/^\d{4}$/.test(dateString)) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
// Handle full dates
|
||||
// Validate date string before creating Date object
|
||||
const date = new Date(dateString);
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
return format(date, 'MMMM d, yyyy');
|
||||
} catch {
|
||||
return dateString;
|
||||
|
||||
@@ -11,6 +11,36 @@ interface EntityEditPreviewProps {
|
||||
entityName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep equality check for detecting changes in nested objects/arrays
|
||||
*/
|
||||
const deepEqual = (a: any, b: any): boolean => {
|
||||
// Handle null/undefined cases
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
// Handle primitives and functions
|
||||
if (typeof a !== 'object') return a === b;
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item, index) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
// One is array, other is not
|
||||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||||
|
||||
// Handle objects
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
return keysA.every(key => deepEqual(a[key], b[key]));
|
||||
};
|
||||
|
||||
interface ImageAssignments {
|
||||
uploaded: Array<{
|
||||
url: string;
|
||||
@@ -51,7 +81,10 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
||||
.eq('submission_id', submissionId)
|
||||
.order('order_index', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
if (error) {
|
||||
console.error('EntityEditPreview.fetchSubmissionItems: Failed to fetch submission items:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (items && items.length > 0) {
|
||||
const firstItem = items[0];
|
||||
@@ -75,21 +108,35 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
||||
if (data.images) {
|
||||
const images: ImageAssignments = data.images;
|
||||
|
||||
// Safety check: verify uploaded array exists and is valid
|
||||
if (!images.uploaded || !Array.isArray(images.uploaded)) {
|
||||
// Invalid images data structure, skip image processing
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract banner image
|
||||
if (images.banner_assignment !== null && images.banner_assignment !== undefined) {
|
||||
const bannerImg = images.uploaded[images.banner_assignment];
|
||||
if (bannerImg) {
|
||||
setBannerImageUrl(bannerImg.url);
|
||||
changed.push('banner_image');
|
||||
// Safety check: verify index is within bounds
|
||||
if (images.banner_assignment >= 0 && images.banner_assignment < images.uploaded.length) {
|
||||
const bannerImg = images.uploaded[images.banner_assignment];
|
||||
// Validate nested image data
|
||||
if (bannerImg && bannerImg.url) {
|
||||
setBannerImageUrl(bannerImg.url);
|
||||
changed.push('banner_image');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract card image
|
||||
if (images.card_assignment !== null && images.card_assignment !== undefined) {
|
||||
const cardImg = images.uploaded[images.card_assignment];
|
||||
if (cardImg) {
|
||||
setCardImageUrl(cardImg.url);
|
||||
changed.push('card_image');
|
||||
// Safety check: verify index is within bounds
|
||||
if (images.card_assignment >= 0 && images.card_assignment < images.uploaded.length) {
|
||||
const cardImg = images.uploaded[images.card_assignment];
|
||||
// Validate nested image data
|
||||
if (cardImg && cardImg.url) {
|
||||
setCardImageUrl(cardImg.url);
|
||||
changed.push('card_image');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,9 +146,12 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
||||
const originalData = firstItem.original_data as any;
|
||||
const excludeFields = ['images', 'updated_at', 'created_at'];
|
||||
Object.keys(data).forEach(key => {
|
||||
if (!excludeFields.includes(key) &&
|
||||
data[key] !== originalData[key]) {
|
||||
changed.push(key);
|
||||
if (!excludeFields.includes(key)) {
|
||||
// Use deep equality check for objects and arrays
|
||||
const isEqual = deepEqual(data[key], originalData[key]);
|
||||
if (!isEqual) {
|
||||
changed.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -109,7 +159,7 @@ export const EntityEditPreview = ({ submissionId, entityType, entityName }: Enti
|
||||
setChangedFields(changed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching submission items:', error);
|
||||
console.error('EntityEditPreview.fetchSubmissionItems: Error fetching submission items:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,19 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal
|
||||
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
// Safety check: ensure photos array exists and is not empty
|
||||
if (!photos || photos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Clamp currentIndex to valid bounds
|
||||
const safeIndex = Math.max(0, Math.min(currentIndex, photos.length - 1));
|
||||
const currentPhoto = photos[safeIndex];
|
||||
|
||||
// Early return if currentPhoto is undefined
|
||||
if (!currentPhoto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Minimum swipe distance (in px)
|
||||
const minSwipeDistance = 50;
|
||||
@@ -100,7 +112,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal
|
||||
)}
|
||||
{photos.length > 1 && (
|
||||
<p className={`text-white/70 ${isMobile ? 'text-xs' : 'text-sm'}`}>
|
||||
{currentIndex + 1} of {photos.length}
|
||||
{safeIndex + 1} of {photos.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -111,7 +123,7 @@ export function PhotoModal({ photos, initialIndex, isOpen, onClose }: PhotoModal
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={currentPhoto?.url}
|
||||
alt={currentPhoto?.caption || `Photo ${currentIndex + 1}`}
|
||||
alt={currentPhoto?.caption || `Photo ${safeIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
|
||||
@@ -296,7 +296,7 @@ export function AutocompleteSearch({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && displayItems.length > 0 && (
|
||||
{isOpen && (displayItems.length > 0 || loading) && (
|
||||
<div className={`absolute top-full mt-1 left-0 right-0 bg-popover border border-border rounded-lg shadow-xl z-[100] max-h-96 overflow-y-auto ${isHero ? 'max-w-2xl mx-auto' : ''}`}>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && showRecentSearches && suggestions.length > 0 && (
|
||||
@@ -316,7 +316,7 @@ export function AutocompleteSearch({
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayItems.map((item, index) => (
|
||||
{displayItems.length > 0 && displayItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleResultClick(item)}
|
||||
@@ -364,10 +364,11 @@ export function AutocompleteSearch({
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
<span className="text-sm text-muted-foreground ml-2">Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.length > 0 && results.length > 0 && (
|
||||
{query.length > 0 && results.length > 0 && !loading && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<Button
|
||||
|
||||
13
src/components/ui/sidebar-context.ts
Normal file
13
src/components/ui/sidebar-context.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
export const SidebarContext = createContext<SidebarContext | null>(null);
|
||||
@@ -11,6 +11,8 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { SidebarContext, type SidebarContext as SidebarContextType } from "@/components/ui/sidebar-context";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
@@ -19,27 +21,6 @@ const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
@@ -92,7 +73,7 @@ const SidebarProvider = React.forwardRef<
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
const contextValue = React.useMemo<SidebarContextType>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
@@ -633,5 +614,7 @@ export {
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
|
||||
// Re-export useSidebar from the hooks file for backwards compatibility
|
||||
export { useSidebar } from "@/hooks/useSidebar";
|
||||
|
||||
@@ -393,53 +393,3 @@ export function EntityMultiImageUploader({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to upload all local files
|
||||
export async function uploadPendingImages(images: UploadedImage[]): Promise<UploadedImage[]> {
|
||||
const uploadedImages: UploadedImage[] = [];
|
||||
|
||||
for (const image of images) {
|
||||
if (image.isLocal && image.file) {
|
||||
try {
|
||||
// Get upload URL from Cloudflare
|
||||
const { data: uploadData, error: uploadError } = await fetch('/api/upload-image', {
|
||||
method: 'POST',
|
||||
}).then(res => res.json());
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
|
||||
// Upload to Cloudflare
|
||||
const formData = new FormData();
|
||||
formData.append('file', image.file);
|
||||
|
||||
const uploadResponse = await fetch(uploadData.uploadURL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload image');
|
||||
}
|
||||
|
||||
const result = await uploadResponse.json();
|
||||
|
||||
// Return uploaded image with Cloudflare data
|
||||
uploadedImages.push({
|
||||
url: result.result.variants[0],
|
||||
cloudflare_id: result.result.id,
|
||||
caption: image.caption,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
// Already uploaded, keep as is
|
||||
uploadedImages.push(image);
|
||||
}
|
||||
}
|
||||
|
||||
return uploadedImages;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user