- Add rotation_degrees parameter to ImageProcessor.process_photo() - Pass rotation through _auto_save_photo_from_extraction() to processor - Allow no-crop fallback when crop_bounds is None - Add buildPhotoUrl() helper to resolve backend URLs correctly - Update frontend components to use backend URL for image sources - Replace Use/Skip Photo buttons with checkbox in AI extraction UI - Add images/ to .gitignore to prevent accidental commits Addresses: rotation never applied, image 404s (relative to Next.js not backend), preview blank in edit form
184 lines
7.1 KiB
TypeScript
184 lines
7.1 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useRef } from 'react';
|
|
import { Item } from '@/lib/db';
|
|
import { inventoryApi, buildPhotoUrl } from '@/lib/api';
|
|
import ItemPhotoUpload from '@/components/ItemPhotoUpload';
|
|
import { X, Camera, Trash2 } from 'lucide-react';
|
|
import { toast } from 'react-hot-toast';
|
|
|
|
interface ItemDetailModalProps {
|
|
item: Item;
|
|
onClose: () => void;
|
|
onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void;
|
|
onItemRefresh?: () => void;
|
|
backendUrl?: string;
|
|
}
|
|
|
|
export default function ItemDetailModal({
|
|
item,
|
|
onClose,
|
|
onPhotoUpdated,
|
|
onItemRefresh,
|
|
backendUrl = '',
|
|
}: ItemDetailModalProps) {
|
|
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
|
const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>(
|
|
item.photo_path && item.photo_thumbnail_path
|
|
? { thumbnail_url: item.photo_thumbnail_path, full_url: item.photo_path }
|
|
: item.image_url
|
|
? { thumbnail_url: item.image_url, full_url: item.image_url }
|
|
: null
|
|
);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
const photoUploadRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handlePhotoUploadSuccess = (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => {
|
|
setCurrentPhoto({ thumbnail_url: photo.thumbnail_url, full_url: photo.full_url });
|
|
setShowPhotoUpload(false);
|
|
onPhotoUpdated?.(photo);
|
|
onItemRefresh?.();
|
|
};
|
|
|
|
const handlePhotoUploadError = (errorMessage: string) => {
|
|
toast.error(errorMessage);
|
|
};
|
|
|
|
const handleDeletePhoto = async () => {
|
|
if (!item.id) return;
|
|
|
|
if (!window.confirm('Delete this photo? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
setIsDeleting(true);
|
|
try {
|
|
await inventoryApi.deleteItemPhoto(item.id);
|
|
setCurrentPhoto(null);
|
|
toast.success('Photo deleted successfully');
|
|
onItemRefresh?.();
|
|
} catch (error: any) {
|
|
const errorMsg = error?.message || 'Failed to delete photo';
|
|
toast.error(errorMsg);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
<div className="bg-surface border border-slate-800 rounded-3xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-surface border-b border-slate-800/50 p-4 md:p-6 flex items-center justify-between">
|
|
<h2 className="text-xl md:text-2xl font-normal text-white truncate">{item.name}</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-slate-800 rounded-full text-muted hover:text-white transition-colors"
|
|
aria-label="Close modal"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="p-4 md:p-6 space-y-6">
|
|
{/* Item Details */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-xs text-muted mb-1">Category</p>
|
|
<p className="text-sm text-white">{item.category}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted mb-1">Type</p>
|
|
<p className="text-sm text-white">{item.type || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted mb-1">Quantity</p>
|
|
<p className="text-sm text-white">{item.quantity}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted mb-1">Part Number</p>
|
|
<p className="text-sm text-white">{item.part_number || 'N/A'}</p>
|
|
</div>
|
|
{item.barcode && (
|
|
<div className="col-span-2">
|
|
<p className="text-xs text-muted mb-1">Barcode</p>
|
|
<p className="text-sm text-white font-mono">{item.barcode}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Photo Section */}
|
|
<div className="border-t border-slate-800/50 pt-6">
|
|
<h3 className="text-lg font-normal text-white mb-4 flex items-center gap-2">
|
|
<Camera size={18} className="text-primary" />
|
|
Photo
|
|
</h3>
|
|
|
|
{!showPhotoUpload ? (
|
|
<>
|
|
{currentPhoto ? (
|
|
<div className="space-y-3">
|
|
<div className="relative bg-slate-900/50 rounded-2xl overflow-hidden border border-slate-800/50 aspect-video max-h-96">
|
|
<img
|
|
src={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
|
alt={item.name}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowPhotoUpload(true)}
|
|
className="flex-1 px-4 py-2 bg-primary/20 border border-primary/50 text-primary rounded-xl hover:bg-primary/30 transition-colors font-normal text-sm"
|
|
>
|
|
Replace Photo
|
|
</button>
|
|
<button
|
|
onClick={handleDeletePhoto}
|
|
disabled={isDeleting}
|
|
className="px-4 py-2 bg-rose-500/20 border border-rose-500/50 text-rose-400 rounded-xl hover:bg-rose-500/30 disabled:opacity-50 transition-colors font-normal text-sm"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-slate-900/30 border border-slate-800/50 rounded-2xl p-8 text-center">
|
|
<p className="text-muted text-sm mb-4">No photo uploaded</p>
|
|
<button
|
|
onClick={() => setShowPhotoUpload(true)}
|
|
className="px-4 py-2 bg-primary/20 border border-primary/50 text-primary rounded-xl hover:bg-primary/30 transition-colors font-normal text-sm"
|
|
>
|
|
Upload Photo
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div ref={photoUploadRef} className="bg-slate-900/30 border border-slate-800/50 rounded-2xl p-4 md:p-6">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h4 className="font-normal text-white">Upload New Photo</h4>
|
|
<button
|
|
onClick={() => setShowPhotoUpload(false)}
|
|
className="p-1 hover:bg-slate-800 rounded-lg text-muted hover:text-white transition-colors"
|
|
aria-label="Cancel upload"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
{item.id && (
|
|
<ItemPhotoUpload
|
|
itemId={item.id}
|
|
onUploadSuccess={handlePhotoUploadSuccess}
|
|
onError={handlePhotoUploadError}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|