From 0b9a5cc5fbf9fa15620c3f25080d534841cf9cbe Mon Sep 17 00:00:00 2001
From: "gpt-engineer-app[bot]"
<159125892+gpt-engineer-app[bot]@users.noreply.github.com>
Date: Wed, 15 Oct 2025 13:04:12 +0000
Subject: [PATCH] feat: Implement photo submission editing and display
enhancements
---
src/components/common/PhotoGrid.tsx | 90 +++++++++++++
.../moderation/PhotoSubmissionDisplay.tsx | 32 ++---
src/hooks/usePhotoSubmissionItems.ts | 74 +++++++++++
src/lib/photoHelpers.ts | 125 ++++++++++++++++++
src/types/photo-submissions.ts | 1 +
src/types/photos.ts | 20 +++
6 files changed, 323 insertions(+), 19 deletions(-)
create mode 100644 src/components/common/PhotoGrid.tsx
create mode 100644 src/hooks/usePhotoSubmissionItems.ts
create mode 100644 src/lib/photoHelpers.ts
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)}
+ >
+

{
+ 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) => (
-

- ))}
- {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;