mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 18:31:12 -05:00
feat: Implement photo submission editing and display enhancements
This commit is contained in:
90
src/components/common/PhotoGrid.tsx
Normal file
90
src/components/common/PhotoGrid.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* PhotoGrid Component
|
||||
* Reusable photo grid display with modal support
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Eye, AlertCircle } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
import { generatePhotoAlt } from '@/lib/photoHelpers';
|
||||
|
||||
interface PhotoGridProps {
|
||||
photos: PhotoItem[];
|
||||
onPhotoClick?: (photos: PhotoItem[], index: number) => void;
|
||||
maxDisplay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PhotoGrid = memo(({
|
||||
photos,
|
||||
onPhotoClick,
|
||||
maxDisplay,
|
||||
className = ''
|
||||
}: PhotoGridProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const defaultMaxDisplay = isMobile ? 2 : 3;
|
||||
const maxToShow = maxDisplay ?? defaultMaxDisplay;
|
||||
const displayPhotos = photos.slice(0, maxToShow);
|
||||
const remainingCount = Math.max(0, photos.length - maxToShow);
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
No photos available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'} ${className}`}>
|
||||
{displayPhotos.map((photo, index) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative cursor-pointer group overflow-hidden rounded-md border bg-muted/30"
|
||||
onClick={() => onPhotoClick?.(photos, index)}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={generatePhotoAlt(photo)}
|
||||
className="w-full h-32 object-cover transition-opacity group-hover:opacity-80"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'absolute inset-0 flex flex-col items-center justify-center text-destructive text-xs p-2';
|
||||
const icon = document.createElement('div');
|
||||
icon.textContent = '⚠️';
|
||||
icon.className = 'text-lg mb-1';
|
||||
const text = document.createElement('div');
|
||||
text.textContent = 'Failed to load';
|
||||
errorDiv.appendChild(icon);
|
||||
errorDiv.appendChild(text);
|
||||
parent.appendChild(errorDiv);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Eye className="w-5 h-5" />
|
||||
</div>
|
||||
{photo.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs p-1 truncate">
|
||||
{photo.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div className="flex items-center justify-center bg-muted rounded-md text-sm text-muted-foreground font-medium">
|
||||
+{remainingCount} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PhotoGrid.displayName = 'PhotoGrid';
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { PhotoGrid } from '@/components/common/PhotoGrid';
|
||||
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
|
||||
import type { PhotoItem } from '@/types/photos';
|
||||
|
||||
interface PhotoSubmissionDisplayProps {
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -114,21 +114,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
||||
{photos.slice(0, isMobile ? 2 : 3).map((photo) => (
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.cloudflare_image_url}
|
||||
alt={photo.title || photo.caption || 'Submitted photo'}
|
||||
className="w-full h-32 object-cover rounded-md"
|
||||
/>
|
||||
))}
|
||||
{photos.length > (isMobile ? 2 : 3) && (
|
||||
<div className="text-sm text-muted-foreground flex items-center justify-center bg-muted rounded-md">
|
||||
+{photos.length - (isMobile ? 2 : 3)} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// Convert PhotoSubmissionItem[] to PhotoItem[] for PhotoGrid
|
||||
const photoItems: PhotoItem[] = photos.map(photo => ({
|
||||
id: photo.id,
|
||||
url: photo.cloudflare_image_url,
|
||||
filename: photo.filename || `Photo ${photo.order_index + 1}`,
|
||||
caption: photo.caption,
|
||||
title: photo.title,
|
||||
date_taken: photo.date_taken,
|
||||
}));
|
||||
|
||||
return <PhotoGrid photos={photoItems} />;
|
||||
}
|
||||
|
||||
74
src/hooks/usePhotoSubmissionItems.ts
Normal file
74
src/hooks/usePhotoSubmissionItems.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Hook: usePhotoSubmissionItems
|
||||
* Fetches photo items from relational tables for a given submission
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
|
||||
|
||||
interface UsePhotoSubmissionItemsResult {
|
||||
photos: PhotoSubmissionItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function usePhotoSubmissionItems(
|
||||
submissionId: string | undefined
|
||||
): UsePhotoSubmissionItemsResult {
|
||||
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!submissionId) {
|
||||
setPhotos([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
fetchPhotoItems();
|
||||
}, [submissionId]);
|
||||
|
||||
const fetchPhotoItems = async () => {
|
||||
if (!submissionId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Step 1: Get photo_submission_id from submission_id
|
||||
const { data: photoSubmission, error: photoSubmissionError } = await supabase
|
||||
.from('photo_submissions')
|
||||
.select('id')
|
||||
.eq('submission_id', submissionId)
|
||||
.maybeSingle();
|
||||
|
||||
if (photoSubmissionError) throw photoSubmissionError;
|
||||
if (!photoSubmission) {
|
||||
setPhotos([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Get photo items using photo_submission_id
|
||||
const { data, error: itemsError } = await supabase
|
||||
.from('photo_submission_items')
|
||||
.select('*')
|
||||
.eq('photo_submission_id', photoSubmission.id)
|
||||
.order('order_index');
|
||||
|
||||
if (itemsError) throw itemsError;
|
||||
|
||||
setPhotos(data || []);
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching photo submission items:', err);
|
||||
setError(err.message || 'Failed to load photos');
|
||||
setPhotos([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { photos, loading, error };
|
||||
}
|
||||
125
src/lib/photoHelpers.ts
Normal file
125
src/lib/photoHelpers.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Photo Helpers
|
||||
* Utilities for normalizing and validating photo data from different sources
|
||||
*/
|
||||
|
||||
import type { PhotoItem, NormalizedPhoto, PhotoDataSource } from '@/types/photos';
|
||||
import type { PhotoSubmissionItem } from '@/types/photo-submissions';
|
||||
|
||||
/**
|
||||
* Type guard: Check if data is a photo submission item
|
||||
*/
|
||||
export function isPhotoSubmissionItem(data: any): data is PhotoSubmissionItem {
|
||||
return (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
'cloudflare_image_id' in data &&
|
||||
'cloudflare_image_url' in data &&
|
||||
'order_index' in data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if content is a review with photos
|
||||
*/
|
||||
export function isReviewWithPhotos(content: any): boolean {
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
Array.isArray(content.photos) &&
|
||||
content.photos.length > 0 &&
|
||||
content.photos[0]?.url
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Check if content is a photo submission with JSONB photos
|
||||
*/
|
||||
export function isPhotoSubmissionWithJsonb(content: any): boolean {
|
||||
return (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
content.content &&
|
||||
Array.isArray(content.content.photos) &&
|
||||
content.content.photos.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize photo data from any source to PhotoItem[]
|
||||
*/
|
||||
export function normalizePhotoData(source: PhotoDataSource): PhotoItem[] {
|
||||
switch (source.type) {
|
||||
case 'review':
|
||||
return source.photos.map((photo, index) => ({
|
||||
id: `review-${index}`,
|
||||
url: photo.url,
|
||||
filename: photo.filename || `Review photo ${index + 1}`,
|
||||
caption: photo.caption,
|
||||
size: photo.size,
|
||||
type: photo.type,
|
||||
}));
|
||||
|
||||
case 'submission_jsonb':
|
||||
return source.photos.map((photo, index) => ({
|
||||
id: `jsonb-${index}`,
|
||||
url: photo.url,
|
||||
filename: photo.filename || `Photo ${index + 1}`,
|
||||
caption: photo.caption,
|
||||
title: photo.title,
|
||||
size: photo.size,
|
||||
type: photo.type,
|
||||
}));
|
||||
|
||||
case 'submission_items':
|
||||
return source.items.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.cloudflare_image_url,
|
||||
filename: item.filename || `Photo ${item.order_index + 1}`,
|
||||
caption: item.caption,
|
||||
title: item.title,
|
||||
date_taken: item.date_taken,
|
||||
}));
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PhotoSubmissionItem[] to NormalizedPhoto[]
|
||||
*/
|
||||
export function normalizePhotoSubmissionItems(
|
||||
items: PhotoSubmissionItem[]
|
||||
): NormalizedPhoto[] {
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
url: item.cloudflare_image_url,
|
||||
filename: item.filename || `Photo ${item.order_index + 1}`,
|
||||
caption: item.caption,
|
||||
title: item.title,
|
||||
date_taken: item.date_taken,
|
||||
order_index: item.order_index,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate photo URL is from Cloudflare Images
|
||||
*/
|
||||
export function isValidCloudflareUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.includes('imagedelivery.net');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate photo alt text from available metadata
|
||||
*/
|
||||
export function generatePhotoAlt(photo: PhotoItem | NormalizedPhoto): string {
|
||||
if (photo.title) return photo.title;
|
||||
if (photo.caption) return photo.caption;
|
||||
return photo.filename || 'Photo';
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface PhotoSubmissionItem {
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
date_taken?: string;
|
||||
date_taken_precision?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Photo-related type definitions
|
||||
*/
|
||||
|
||||
// Core photo display interface
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
url: string;
|
||||
@@ -9,8 +10,27 @@ export interface PhotoItem {
|
||||
caption?: string;
|
||||
size?: number;
|
||||
type?: string;
|
||||
title?: string;
|
||||
date_taken?: string;
|
||||
}
|
||||
|
||||
// Normalized photo for consistent display
|
||||
export interface NormalizedPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
filename: string;
|
||||
caption?: string;
|
||||
title?: string;
|
||||
date_taken?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
// Photo data source types
|
||||
export type PhotoDataSource =
|
||||
| { type: 'review'; photos: any[] }
|
||||
| { type: 'submission_jsonb'; photos: any[] }
|
||||
| { type: 'submission_items'; items: PhotoSubmissionItem[] };
|
||||
|
||||
export interface PhotoSubmissionItem {
|
||||
id: string;
|
||||
cloudflare_image_id: string;
|
||||
|
||||
Reference in New Issue
Block a user