189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { Modal } from '@/components/ui/Modal';
|
|
import {
|
|
listFiles,
|
|
uploadFile,
|
|
deleteFile,
|
|
isImageMime,
|
|
getThumbnailUrl,
|
|
getFileDownloadUrl,
|
|
type FileResponse,
|
|
} from '@/lib/files';
|
|
|
|
interface VehicleImagesProps {
|
|
vehicleId: string;
|
|
}
|
|
|
|
export function VehicleImages({ vehicleId }: VehicleImagesProps) {
|
|
const [files, setFiles] = useState<FileResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const fetchFiles = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const data = await listFiles(vehicleId, 1, 100);
|
|
const imageFiles = data.items.filter(f => isImageMime(f.mime_type));
|
|
setFiles(imageFiles);
|
|
} catch (err: unknown) {
|
|
const apiErr = err as { error?: { message?: string } };
|
|
setError(apiErr?.error?.message || 'Failed to load images');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [vehicleId]);
|
|
|
|
useEffect(() => {
|
|
fetchFiles();
|
|
}, [fetchFiles]);
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFiles = e.target.files;
|
|
if (!selectedFiles || selectedFiles.length === 0) return;
|
|
|
|
setUploading(true);
|
|
setError(null);
|
|
try {
|
|
for (let i = 0; i < selectedFiles.length; i++) {
|
|
await uploadFile(vehicleId, selectedFiles[i]);
|
|
}
|
|
await fetchFiles();
|
|
} catch (err: unknown) {
|
|
const apiErr = err as { error?: { message?: string } };
|
|
setError(apiErr?.error?.message || 'Failed to upload images');
|
|
} finally {
|
|
setUploading(false);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (fileId: string) => {
|
|
if (!confirm('Delete this image?')) return;
|
|
try {
|
|
await deleteFile(vehicleId, fileId);
|
|
setFiles(prev => prev.filter(f => f.id !== fileId));
|
|
if (selectedImage?.id === fileId) {
|
|
setSelectedImage(null);
|
|
}
|
|
} catch (err: unknown) {
|
|
const apiErr = err as { error?: { message?: string } };
|
|
setError(apiErr?.error?.message || 'Failed to delete image');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div data-testid="vehicle-images" className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold text-text">Bilder</h2>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
data-testid="image-upload-input"
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
onChange={handleUpload}
|
|
className="hidden"
|
|
/>
|
|
<Button
|
|
data-testid="upload-image-button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
loading={uploading}
|
|
disabled={uploading}
|
|
>
|
|
Bild hochladen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div data-testid="images-error" className="p-4 bg-error/10 text-error rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div data-testid="images-loading" className="text-center py-8 text-text-muted">
|
|
Loading images...
|
|
</div>
|
|
) : files.length === 0 ? (
|
|
<div data-testid="no-images" className="text-center py-8 text-text-muted">
|
|
Keine Bilder vorhanden
|
|
</div>
|
|
) : (
|
|
<div
|
|
data-testid="image-grid"
|
|
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
|
>
|
|
{files.map(file => {
|
|
const thumbUrl = getThumbnailUrl(file) || getFileDownloadUrl(vehicleId, file.id);
|
|
return (
|
|
<div
|
|
key={file.id}
|
|
data-testid={`image-item-${file.id}`}
|
|
className="relative group rounded-lg overflow-hidden border border-border bg-surface"
|
|
>
|
|
<button
|
|
onClick={() => setSelectedImage(file)}
|
|
className="block w-full aspect-square"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={thumbUrl}
|
|
alt={file.original_filename}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</button>
|
|
<button
|
|
data-testid={`delete-image-${file.id}`}
|
|
onClick={() => handleDelete(file.id)}
|
|
className="absolute top-2 right-2 bg-error text-white rounded-full p-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
aria-label="Delete image"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<Modal
|
|
open={selectedImage !== null}
|
|
onClose={() => setSelectedImage(null)}
|
|
title={selectedImage?.original_filename}
|
|
>
|
|
{selectedImage && (
|
|
<div className="flex flex-col items-center">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={getFileDownloadUrl(vehicleId, selectedImage.id)}
|
|
alt={selectedImage.original_filename}
|
|
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
|
/>
|
|
<div className="mt-4 flex gap-3">
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => handleDelete(selectedImage.id)}
|
|
>
|
|
Löschen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|