Files
flyer-crawler.projectium.com/src/components/FlyerCorrectionTool.tsx

249 lines
9.2 KiB
TypeScript

// src/components/FlyerCorrectionTool.tsx
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { XCircleIcon } from './icons/XCircleIcon';
import { ScissorsIcon } from './icons/ScissorsIcon';
import { RefreshCwIcon } from './icons/RefreshCwIcon';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError, notifySuccess } from '../services/notificationService';
import { logger } from '../services/logger.client';
export interface FlyerCorrectionToolProps {
isOpen: boolean;
onClose: () => void;
imageUrl: string;
onDataExtracted: (type: 'store_name' | 'dates', value: string) => void;
}
type Rect = { x: number; y: number; width: number; height: number };
type ExtractionType = 'store_name' | 'dates';
export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({
isOpen,
onClose,
imageUrl,
onDataExtracted,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [selectionRect, setSelectionRect] = useState<Rect | null>(null);
const [startPoint, setStartPoint] = useState<{ x: number; y: number } | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
// Fetch the image and store it as a File object for API submission
useEffect(() => {
if (isOpen && imageUrl) {
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
fetch(imageUrl)
.then((res) => res.blob())
.then((blob) => {
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
setImageFile(file);
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
})
.catch((err) => {
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
logger.error({ error: err }, 'Failed to fetch image for correction tool');
notifyError('Could not load the image for correction.');
});
}
}, [isOpen, imageUrl]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const image = imageRef.current;
if (!canvas || !image) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to match image display size
canvas.width = image.clientWidth;
canvas.height = image.clientHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (selectionRect) {
ctx.strokeStyle = '#f59e0b'; // amber-500
ctx.lineWidth = 2;
ctx.setLineDash([6, 3]);
ctx.strokeRect(selectionRect.x, selectionRect.y, selectionRect.width, selectionRect.height);
}
}, [selectionRect]);
useEffect(() => {
draw();
const handleResize = () => draw();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [draw]);
const getCanvasCoordinates = (
e: React.MouseEvent<HTMLCanvasElement>,
): { x: number; y: number } => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
setIsDrawing(true);
setStartPoint(getCanvasCoordinates(e));
setSelectionRect(null);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !startPoint) return;
const currentPoint = getCanvasCoordinates(e);
const rect = {
x: Math.min(startPoint.x, currentPoint.x),
y: Math.min(startPoint.y, currentPoint.y),
width: Math.abs(startPoint.x - currentPoint.x),
height: Math.abs(startPoint.y - currentPoint.y),
};
setSelectionRect(rect);
};
const handleMouseUp = () => {
setIsDrawing(false);
setStartPoint(null);
console.debug('[DEBUG] FlyerCorrectionTool: Mouse Up - selection complete.', { selectionRect });
};
const handleRescan = async (type: ExtractionType) => {
console.debug(`[DEBUG] handleRescan triggered for type: ${type}`);
console.debug(
`[DEBUG] handleRescan state: selectionRect=${!!selectionRect}, imageRef=${!!imageRef.current}, imageFile=${!!imageFile}`,
);
if (!selectionRect || !imageRef.current || !imageFile) {
console.warn('[DEBUG] handleRescan: Guard failed. Missing prerequisites.');
if (!selectionRect) console.warn('[DEBUG] Reason: No selectionRect');
if (!imageRef.current) console.warn('[DEBUG] Reason: No imageRef');
if (!imageFile) console.warn('[DEBUG] Reason: No imageFile');
notifyError('Please select an area on the image first.');
return;
}
console.debug(`[DEBUG] handleRescan: Prerequisites met. Starting processing for "${type}".`);
setIsProcessing(true);
try {
// Scale selection coordinates to the original image dimensions
const image = imageRef.current;
const scaleX = image.naturalWidth / image.clientWidth;
const scaleY = image.naturalHeight / image.clientHeight;
const cropArea = {
x: selectionRect.x * scaleX,
y: selectionRect.y * scaleY,
width: selectionRect.width * scaleX,
height: selectionRect.height * scaleY,
};
console.debug('[DEBUG] handleRescan: Calculated scaled cropArea:', cropArea);
console.debug('[DEBUG] handleRescan: Awaiting aiApiClient.rescanImageArea...');
const response = await aiApiClient.rescanImageArea(imageFile, cropArea, type);
console.debug('[DEBUG] handleRescan: API call returned. Response ok:', response.ok);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to rescan area.');
}
const { text } = await response.json();
console.debug('[DEBUG] handleRescan: Successfully extracted text:', text);
notifySuccess(`Extracted: ${text}`);
onDataExtracted(type, text);
onClose(); // Close modal on success
} catch (err) {
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
notifyError(msg);
logger.error({ error: err }, 'Error during rescan:');
} finally {
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
setIsProcessing(false);
}
};
if (!isOpen) return null;
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', {
isProcessing,
hasSelection: !!selectionRect,
});
return (
<div
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4"
onClick={onClose}
>
<div
role="dialog"
className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center p-4 border-b border-gray-700">
<h2 className="text-lg font-semibold text-white flex items-center">
<ScissorsIcon className="w-6 h-6 mr-2" /> Flyer Correction Tool
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white"
aria-label="Close correction tool"
>
<XCircleIcon className="w-7 h-7" />
</button>
</div>
<div className="grow p-4 overflow-auto relative flex justify-center items-center">
<img
ref={imageRef}
src={imageUrl}
alt="Flyer for correction"
className="max-w-full max-h-full object-contain"
onLoad={draw}
/>
<canvas
ref={canvasRef}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
/>
</div>
<div className="p-4 border-t border-gray-700 flex items-center justify-center space-x-4">
{isProcessing ? (
<div className="flex items-center text-white">
<RefreshCwIcon className="w-5 h-5 mr-2 animate-spin" />
<span>Processing...</span>
</div>
) : (
<>
<button
onClick={() => handleRescan('store_name')}
disabled={!selectionRect || isProcessing}
className="px-4 py-2 bg-blue-600 text-white rounded-md disabled:bg-gray-500 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors"
>
Extract Store Name
</button>
<button
onClick={() => handleRescan('dates')}
disabled={!selectionRect || isProcessing}
className="px-4 py-2 bg-green-600 text-white rounded-md disabled:bg-gray-500 disabled:cursor-not-allowed hover:bg-green-700 transition-colors"
>
Extract Sale Dates
</button>
</>
)}
</div>
</div>
</div>
);
};