Much better UX for identifying correct rotation: - Shows ORIGINAL image scaled to fit the modal - Draws GREEN bounding box showing the crop area - Draws ORANGE rotated rectangle showing rotation effect - Darkens everything outside the crop area (visual focus) - Rotation slider updates the overlay in real-time - Quick preset buttons for common angles - Detailed debug logs showing all calculations - Better styling: dark theme for readability User can now visually see what will be extracted with the current rotation angle. Report the angle that makes the label readable.
210 lines
8.3 KiB
TypeScript
210 lines
8.3 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 { DebugRotationPanel } from '@/components/DebugRotationPanel';
|
|
import { X, Camera, Trash2, Wrench } 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 [showDebugPanel, setShowDebugPanel] = 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>
|
|
<div className="flex items-center gap-2">
|
|
{currentPhoto && (
|
|
<button
|
|
onClick={() => setShowDebugPanel(true)}
|
|
className="p-2 hover:bg-yellow-900/50 rounded-full text-yellow-400 hover:text-yellow-300 transition-colors"
|
|
title="Debug rotation & crop"
|
|
aria-label="Debug panel"
|
|
>
|
|
<Wrench size={20} />
|
|
</button>
|
|
)}
|
|
<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>
|
|
</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>
|
|
|
|
{/* Debug Rotation Panel */}
|
|
{showDebugPanel && currentPhoto && (
|
|
<DebugRotationPanel
|
|
item={item}
|
|
imageUrl={buildPhotoUrl(backendUrl, currentPhoto.full_url)}
|
|
originalCropBounds={item.labels_data ? JSON.parse(item.labels_data).image_processing?.crop_bounds : undefined}
|
|
originalRotation={item.labels_data ? JSON.parse(item.labels_data).image_processing?.rotation_degrees : undefined}
|
|
imageProcessing={item.labels_data ? JSON.parse(item.labels_data).image_processing : undefined}
|
|
onClose={() => setShowDebugPanel(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|