223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Button } from '@/Components/ui/button';
|
|
import { Badge } from '@/Components/ui/badge';
|
|
import { Camera, Image as ImageIcon, Trash2, X, Maximize2, UploadCloud } from 'lucide-react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/Components/ui/dialog';
|
|
|
|
export interface EvidenceMedia {
|
|
id: number;
|
|
name: string;
|
|
file_name: string;
|
|
original_url: string;
|
|
mime_type: string;
|
|
size: number;
|
|
}
|
|
|
|
interface Props {
|
|
existingEvidence?: EvidenceMedia[];
|
|
onFilesSelected?: (files: File[]) => void;
|
|
onUploadNewFiles?: (files: File[]) => void;
|
|
onDeleteExistingMedia?: (mediaId: number) => void;
|
|
readOnly?: boolean;
|
|
compact?: boolean;
|
|
label?: string;
|
|
uploading?: boolean;
|
|
}
|
|
|
|
export default function TaskEvidenceUploader({
|
|
existingEvidence = [],
|
|
onFilesSelected,
|
|
onUploadNewFiles,
|
|
onDeleteExistingMedia,
|
|
readOnly = false,
|
|
compact = false,
|
|
label = 'Evidence Photos',
|
|
uploading = false,
|
|
}: Props) {
|
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
const [previews, setPreviews] = useState<string[]>([]);
|
|
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (!e.target.files) return;
|
|
const newFiles = Array.from(e.target.files);
|
|
const updatedFiles = [...selectedFiles, ...newFiles];
|
|
setSelectedFiles(updatedFiles);
|
|
|
|
// Generate preview URLs
|
|
const newPreviews = newFiles.map(file => URL.createObjectURL(file));
|
|
setPreviews(prev => [...prev, ...newPreviews]);
|
|
|
|
if (onFilesSelected) {
|
|
onFilesSelected(updatedFiles);
|
|
}
|
|
};
|
|
|
|
const removeNewFile = (index: number) => {
|
|
const updatedFiles = selectedFiles.filter((_, i) => i !== index);
|
|
const updatedPreviews = previews.filter((_, i) => i !== index);
|
|
|
|
// Revoke Object URL
|
|
URL.revokeObjectURL(previews[index]);
|
|
|
|
setSelectedFiles(updatedFiles);
|
|
setPreviews(updatedPreviews);
|
|
|
|
if (onFilesSelected) {
|
|
onFilesSelected(updatedFiles);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-semibold text-slate-700 flex items-center gap-1.5">
|
|
<Camera className="h-4 w-4 text-indigo-600" />
|
|
{label}
|
|
{(existingEvidence.length > 0 || selectedFiles.length > 0) && (
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-mono">
|
|
{existingEvidence.length + selectedFiles.length} photos
|
|
</Badge>
|
|
)}
|
|
</label>
|
|
|
|
{!readOnly && (
|
|
<label className="cursor-pointer inline-flex items-center gap-1.5 text-xs font-semibold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-3 py-1.5 rounded-lg border border-indigo-200/60 transition-colors">
|
|
<UploadCloud className="h-4 w-4" />
|
|
<span>Add Photos</span>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Photos Grid */}
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2.5">
|
|
{/* Existing Saved Evidence Media */}
|
|
{existingEvidence.map((media) => (
|
|
<div key={media.id} className="group relative rounded-xl border border-slate-200 bg-slate-100 overflow-hidden aspect-square shadow-xs hover:shadow-md transition-all">
|
|
<img
|
|
src={media.original_url}
|
|
alt={media.name}
|
|
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
|
|
onClick={() => setLightboxUrl(media.original_url)}
|
|
/>
|
|
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setLightboxUrl(media.original_url)}
|
|
className="h-7 w-7 text-white hover:bg-white/20 rounded-full"
|
|
>
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
{!readOnly && onDeleteExistingMedia && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => onDeleteExistingMedia(media.id)}
|
|
className="h-7 w-7 text-rose-400 hover:text-rose-200 hover:bg-rose-900/40 rounded-full"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Newly Selected Preview Images */}
|
|
{previews.map((src, idx) => (
|
|
<div key={idx} className="group relative rounded-xl border-2 border-dashed border-indigo-400 bg-indigo-50/50 overflow-hidden aspect-square shadow-xs">
|
|
<img
|
|
src={src}
|
|
alt={`Preview ${idx + 1}`}
|
|
className="w-full h-full object-cover cursor-pointer"
|
|
onClick={() => setLightboxUrl(src)}
|
|
/>
|
|
<Badge className="absolute top-1 left-1 text-[9px] px-1 py-0 bg-indigo-600 text-white font-bold">
|
|
NEW
|
|
</Badge>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeNewFile(idx)}
|
|
className="absolute top-1 right-1 h-6 w-6 bg-slate-900/70 text-white hover:bg-rose-600 rounded-full p-1"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{/* Empty Dropzone Placeholder */}
|
|
{!readOnly && existingEvidence.length === 0 && selectedFiles.length === 0 && (
|
|
<label className="col-span-full border-2 border-dashed border-slate-200 rounded-xl p-4 flex flex-col items-center justify-center text-center cursor-pointer hover:border-indigo-300 hover:bg-indigo-50/20 transition-all">
|
|
<ImageIcon className="h-8 w-8 text-slate-300 mb-1" />
|
|
<span className="text-xs font-medium text-slate-600">No evidence photos attached yet</span>
|
|
<span className="text-[10px] text-slate-400 mt-0.5">Click or drag images here to upload task evidence (.jpg, .png, .webp)</span>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept="image/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Action button to save/upload newly selected evidence photos */}
|
|
{selectedFiles.length > 0 && onUploadNewFiles && (
|
|
<div className="flex justify-end pt-1">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={uploading}
|
|
onClick={() => {
|
|
onUploadNewFiles(selectedFiles);
|
|
setSelectedFiles([]);
|
|
setPreviews([]);
|
|
}}
|
|
className="bg-indigo-600 hover:bg-indigo-700 text-white font-semibold shadow-xs flex items-center gap-1.5"
|
|
>
|
|
<UploadCloud className="h-4 w-4" />
|
|
<span>Upload {selectedFiles.length} Evidence {selectedFiles.length === 1 ? 'Photo' : 'Photos'}</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image Preview Lightbox Dialog */}
|
|
<Dialog open={!!lightboxUrl} onOpenChange={() => setLightboxUrl(null)}>
|
|
<DialogContent className="max-w-4xl bg-slate-950/95 border-slate-800 text-white p-4">
|
|
<DialogHeader className="flex flex-row items-center justify-between pb-2 border-b border-slate-800">
|
|
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-slate-200">
|
|
<Camera className="h-4 w-4 text-indigo-400" /> Evidence Photo Preview
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
{lightboxUrl && (
|
|
<div className="flex items-center justify-center py-4 max-h-[80vh]">
|
|
<img
|
|
src={lightboxUrl}
|
|
alt="Evidence full view"
|
|
className="max-h-[75vh] max-w-full object-contain rounded-lg shadow-2xl border border-slate-800"
|
|
/>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|