341d0c6f38
Backend (ruff): - F821: Add TYPE_CHECKING imports for Vehicle, Contact, File in models (file.py, retouch.py, sale.py, vehicle.py) - E741: Rename ambiguous variable to / (copilot_service.py, price_compare_service.py, openrouter.py) - F841: Remove unused variable in sale_service.py Frontend (ESLint): - react/no-unescaped-entities: Escape quotes in ContractPreview.tsx - @next/next/no-img-element: Replace <img> with <Image> from next/image (FileGallery.tsx, FileList.tsx, FilePreview.tsx, BeforeAfterSlider.tsx) Tests: 392 backend passed, 112 frontend passed, next build successful
107 lines
3.1 KiB
TypeScript
107 lines
3.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
listFiles,
|
|
isImageMime,
|
|
getThumbnailUrl,
|
|
type FileResponse,
|
|
} from '@/lib/files';
|
|
import Image from 'next/image';
|
|
import { FilePreview } from './FilePreview';
|
|
|
|
interface FileGalleryProps {
|
|
vehicleId: string;
|
|
}
|
|
|
|
export function FileGallery({ vehicleId }: FileGalleryProps) {
|
|
const [images, setImages] = useState<FileResponse[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
|
|
|
|
const fetchImages = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
// Fetch all files and filter for images
|
|
const data = await listFiles(vehicleId, 1, 100);
|
|
const imageFiles = data.items.filter((f) => isImageMime(f.mime_type));
|
|
setImages(imageFiles);
|
|
} catch (err: unknown) {
|
|
const apiErr = err as { error?: { message?: string } };
|
|
setError(apiErr?.error?.message || 'Failed to load images');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [vehicleId]);
|
|
|
|
useEffect(() => {
|
|
fetchImages();
|
|
}, [fetchImages]);
|
|
|
|
if (loading && images.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-text-muted" data-testid="gallery-loading">
|
|
Loading gallery...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="text-center py-8 text-error" data-testid="gallery-error">
|
|
{error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (images.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-text-muted" data-testid="gallery-empty">
|
|
No images available for this vehicle.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div data-testid="file-gallery">
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
|
{images.map((image) => {
|
|
const thumbUrl = getThumbnailUrl(image);
|
|
return (
|
|
<div
|
|
key={image.id}
|
|
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
|
onClick={() => setSelectedImage(image)}
|
|
data-testid={`gallery-item-${image.id}`}
|
|
>
|
|
{thumbUrl && (
|
|
<Image
|
|
src={thumbUrl}
|
|
alt={image.original_filename}
|
|
fill
|
|
className="object-cover transition-transform group-hover:scale-105"
|
|
unoptimized
|
|
/>
|
|
)}
|
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-end p-2">
|
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity truncate">
|
|
{image.original_filename}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Full preview modal */}
|
|
<FilePreview
|
|
file={selectedImage}
|
|
open={!!selectedImage}
|
|
onClose={() => setSelectedImage(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|