mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-23 22:11:24 -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 { useState, useEffect } from 'react';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
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 { PhotoSubmissionItem } from '@/types/photo-submissions';
|
||||||
|
import type { PhotoItem } from '@/types/photos';
|
||||||
|
|
||||||
interface PhotoSubmissionDisplayProps {
|
interface PhotoSubmissionDisplayProps {
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
|
export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayProps) {
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
|
const [photos, setPhotos] = useState<PhotoSubmissionItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -114,21 +114,15 @@ export function PhotoSubmissionDisplay({ submissionId }: PhotoSubmissionDisplayP
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Convert PhotoSubmissionItem[] to PhotoItem[] for PhotoGrid
|
||||||
<div className={`grid gap-2 ${isMobile ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
const photoItems: PhotoItem[] = photos.map(photo => ({
|
||||||
{photos.slice(0, isMobile ? 2 : 3).map((photo) => (
|
id: photo.id,
|
||||||
<img
|
url: photo.cloudflare_image_url,
|
||||||
key={photo.id}
|
filename: photo.filename || `Photo ${photo.order_index + 1}`,
|
||||||
src={photo.cloudflare_image_url}
|
caption: photo.caption,
|
||||||
alt={photo.title || photo.caption || 'Submitted photo'}
|
title: photo.title,
|
||||||
className="w-full h-32 object-cover rounded-md"
|
date_taken: photo.date_taken,
|
||||||
/>
|
}));
|
||||||
))}
|
|
||||||
{photos.length > (isMobile ? 2 : 3) && (
|
return <PhotoGrid photos={photoItems} />;
|
||||||
<div className="text-sm text-muted-foreground flex items-center justify-center bg-muted rounded-md">
|
|
||||||
+{photos.length - (isMobile ? 2 : 3)} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
file_size?: number;
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
date_taken?: string;
|
date_taken?: string;
|
||||||
|
date_taken_precision?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Photo-related type definitions
|
* Photo-related type definitions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Core photo display interface
|
||||||
export interface PhotoItem {
|
export interface PhotoItem {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -9,8 +10,27 @@ export interface PhotoItem {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
type?: string;
|
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 {
|
export interface PhotoSubmissionItem {
|
||||||
id: string;
|
id: string;
|
||||||
cloudflare_image_id: string;
|
cloudflare_image_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user