diff --git a/src/components/common/PhotoGrid.tsx b/src/components/common/PhotoGrid.tsx new file mode 100644 index 00000000..54fa7c56 --- /dev/null +++ b/src/components/common/PhotoGrid.tsx @@ -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 ( +
+ + No photos available +
+ ); + } + + return ( +
+ {displayPhotos.map((photo, index) => ( +
onPhotoClick?.(photos, index)} + > + {generatePhotoAlt(photo)} { + 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); + } + }} + /> +
+ +
+ {photo.caption && ( +
+ {photo.caption} +
+ )} +
+ ))} + {remainingCount > 0 && ( +
+ +{remainingCount} more +
+ )} +
+ ); +}); + +PhotoGrid.displayName = 'PhotoGrid'; diff --git a/src/components/moderation/PhotoSubmissionDisplay.tsx b/src/components/moderation/PhotoSubmissionDisplay.tsx index 3b002d3d..be1d872c 100644 --- a/src/components/moderation/PhotoSubmissionDisplay.tsx +++ b/src/components/moderation/PhotoSubmissionDisplay.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -114,21 +114,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP ); } - return ( -
- {photos.slice(0, isMobile ? 2 : 3).map((photo) => ( - {photo.title - ))} - {photos.length > (isMobile ? 2 : 3) && ( -
- +{photos.length - (isMobile ? 2 : 3)} more -
- )} -
- ); + // 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 ; } diff --git a/src/hooks/usePhotoSubmissionItems.ts b/src/hooks/usePhotoSubmissionItems.ts new file mode 100644 index 00000000..21afdf84 --- /dev/null +++ b/src/hooks/usePhotoSubmissionItems.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/src/lib/photoHelpers.ts b/src/lib/photoHelpers.ts new file mode 100644 index 00000000..baefe1ab --- /dev/null +++ b/src/lib/photoHelpers.ts @@ -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'; +} diff --git a/src/types/photo-submissions.ts b/src/types/photo-submissions.ts index fb555d56..53b8e0e2 100644 --- a/src/types/photo-submissions.ts +++ b/src/types/photo-submissions.ts @@ -23,6 +23,7 @@ export interface PhotoSubmissionItem { file_size?: number; mime_type?: string; date_taken?: string; + date_taken_precision?: string; created_at: string; } diff --git a/src/types/photos.ts b/src/types/photos.ts index d32be486..7e3fac63 100644 --- a/src/types/photos.ts +++ b/src/types/photos.ts @@ -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;