feat: Implement photo submission editing and display enhancements

This commit is contained in:
gpt-engineer-app[bot]
2025-10-15 13:04:12 +00:00
parent 97337ed7a3
commit 0b9a5cc5fb
6 changed files with 323 additions and 19 deletions

View 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';

View File

@@ -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} />;
}

View 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
View 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';
}

View File

@@ -23,6 +23,7 @@ export interface PhotoSubmissionItem {
file_size?: number;
mime_type?: string;
date_taken?: string;
date_taken_precision?: string;
created_at: string;
}

View File

@@ -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;