updates to visual search form, show close matches if they exist
This commit is contained in:
@@ -156,18 +156,19 @@ class Domain::PostsController < DomainController
|
||||
@uploaded_image_data_uri = create_thumbnail(image_path, content_type)
|
||||
@uploaded_hash_value = generate_fingerprint(image_path)
|
||||
@uploaded_detail_hash_value = generate_detail_fingerprint(image_path)
|
||||
@post_file_fingerprints =
|
||||
similar_fingerprints =
|
||||
helpers.find_similar_fingerprints(
|
||||
fingerprint_value: @uploaded_hash_value,
|
||||
fingerprint_detail_value: @uploaded_detail_hash_value,
|
||||
).to_a
|
||||
@post_file_fingerprints = @post_file_fingerprints.take(10)
|
||||
@posts =
|
||||
@post_file_fingerprints
|
||||
.map(&:fingerprint)
|
||||
.map(&:post_file)
|
||||
.compact
|
||||
.map(&:post)
|
||||
).take(10)
|
||||
|
||||
@matches = similar_fingerprints
|
||||
@good_matches =
|
||||
similar_fingerprints.select { |f| f.similarity_percentage >= 80 }
|
||||
@bad_matches =
|
||||
similar_fingerprints.select { |f| f.similarity_percentage < 80 }
|
||||
|
||||
@matches = @good_matches if @good_matches.any?
|
||||
ensure
|
||||
# Clean up any temporary files
|
||||
if @temp_file
|
||||
|
||||
@@ -11,6 +11,12 @@ interface FeedbackMessage {
|
||||
type: 'success' | 'error' | 'warning';
|
||||
}
|
||||
|
||||
interface ImageState {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
originalFileSize: number | null;
|
||||
}
|
||||
|
||||
const ACCEPTED_IMAGE_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
@@ -22,11 +28,375 @@ const ACCEPTED_IMAGE_TYPES = [
|
||||
const ACCEPTED_EXTENSIONS =
|
||||
'image/png,image/jpeg,image/jpg,image/gif,image/webp';
|
||||
|
||||
// Feedback Message Component
|
||||
interface FeedbackMessageProps {
|
||||
message: FeedbackMessage;
|
||||
}
|
||||
|
||||
function FeedbackMessageDisplay({ message }: FeedbackMessageProps) {
|
||||
const getClassName = (type: FeedbackMessage['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warning':
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-slate-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<p className={`text-sm ${getClassName(message.type)} mt-2`}>
|
||||
{message.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Image Preview Component
|
||||
interface ImagePreviewProps {
|
||||
imageState: ImageState;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
// Helper function to format file size
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
// Helper function to resize images larger than 2MB
|
||||
async function resizeImageIfNeeded(file: File): Promise<File> {
|
||||
const MAX_SIZE_MB = 2;
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024;
|
||||
|
||||
if (file.size <= MAX_SIZE_BYTES) {
|
||||
return file;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Calculate compression ratio based on file size
|
||||
const compressionRatio = Math.sqrt(MAX_SIZE_BYTES / file.size);
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
const newWidth = Math.floor(img.width * compressionRatio);
|
||||
const newHeight = Math.floor(img.height * compressionRatio);
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
// Draw resized image
|
||||
ctx.drawImage(img, 0, 0, newWidth, newHeight);
|
||||
|
||||
// Convert to blob with quality adjustment
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
const resizedFile = new File([blob], file.name, {
|
||||
type: file.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
resolve(resizedFile);
|
||||
} else {
|
||||
resolve(file); // Fallback to original if resize fails
|
||||
}
|
||||
},
|
||||
file.type,
|
||||
0.85, // Quality setting (85%)
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
resolve(file); // Fallback to original if image load fails
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function ImagePreview({ imageState, onRemove }: ImagePreviewProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={imageState.previewUrl}
|
||||
alt="Selected image thumbnail"
|
||||
className="max-h-32 max-w-32 flex-shrink-0 rounded-md object-cover shadow-sm"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<h3 className="text-sm font-medium text-green-700">Selected Image</h3>
|
||||
<p
|
||||
className="max-w-32 truncate text-xs text-green-600"
|
||||
title={imageState.file.name}
|
||||
>
|
||||
{imageState.file.name}
|
||||
</p>
|
||||
{imageState.originalFileSize ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
<div>Original: {formatFileSize(imageState.originalFileSize)}</div>
|
||||
<div>Resized: {formatFileSize(imageState.file.size)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatFileSize(imageState.file.size)}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="mt-1 self-start rounded bg-slate-600 px-2 py-1 text-xs font-medium text-slate-100 transition-colors hover:bg-red-600 focus:bg-red-600 focus:outline-none"
|
||||
title="Clear image"
|
||||
>
|
||||
Remove Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty Drop Zone Component
|
||||
interface EmptyDropZoneProps {
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
function EmptyDropZone({ isMobile }: EmptyDropZoneProps) {
|
||||
return (
|
||||
<div className="m-4 flex flex-col items-center justify-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-10 w-10 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="hidden font-medium text-slate-600 sm:block">
|
||||
Drag and drop an image here
|
||||
</p>
|
||||
<p className="block font-medium text-slate-600 sm:hidden">
|
||||
tap here to paste an image from the clipboard
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">or use one of the options below</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Image Drop Zone Component
|
||||
interface ImageDropZoneProps {
|
||||
imageState: ImageState | null;
|
||||
isDragOver: boolean;
|
||||
isMobile: boolean;
|
||||
feedbackMessage: FeedbackMessage | null;
|
||||
onClearImage: () => void;
|
||||
onDragEnter: (e: React.DragEvent) => void;
|
||||
onDragLeave: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onClick: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onPaste: (e: ClipboardEvent) => void | Promise<void>;
|
||||
pasteInputRef: React.RefObject<HTMLInputElement>;
|
||||
dropZoneRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ImageDropZone({
|
||||
imageState,
|
||||
isDragOver,
|
||||
isMobile,
|
||||
feedbackMessage,
|
||||
onClearImage,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
pasteInputRef,
|
||||
dropZoneRef,
|
||||
}: ImageDropZoneProps) {
|
||||
return (
|
||||
<div
|
||||
ref={dropZoneRef}
|
||||
onClick={onClick}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`relative mb-4 rounded-lg border-2 border-dashed p-2 text-center transition-colors duration-200 focus:border-blue-500 ${
|
||||
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-slate-300'
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<input
|
||||
ref={pasteInputRef}
|
||||
type="text"
|
||||
className="pointer-events-none absolute opacity-0"
|
||||
style={{ left: '-9999px' }}
|
||||
autoComplete="off"
|
||||
onPaste={async (e) => await onPaste(e.nativeEvent)}
|
||||
onContextMenu={(e) => isMobile && e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
{imageState ? (
|
||||
<ImagePreview imageState={imageState} onRemove={onClearImage} />
|
||||
) : (
|
||||
<EmptyDropZone isMobile={isMobile} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{feedbackMessage && <FeedbackMessageDisplay message={feedbackMessage} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File Upload Section Component
|
||||
interface FileUploadSectionProps {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileChange: (file: File | null) => Promise<void>;
|
||||
}
|
||||
|
||||
function FileUploadSection({
|
||||
fileInputRef,
|
||||
onFileChange,
|
||||
}: FileUploadSectionProps) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">Upload an image</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="image-file-input"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
>
|
||||
Choose an image file
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="image-file-input"
|
||||
name="image_file"
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="w-full rounded-md border-slate-300 text-sm"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
await onFileChange(file || null);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Supported formats: JPG, PNG, GIF, WebP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// URL Upload Section Component
|
||||
interface UrlUploadSectionProps {
|
||||
imageUrl: string;
|
||||
onUrlChange: (url: string) => void;
|
||||
}
|
||||
|
||||
function UrlUploadSection({ imageUrl, onUrlChange }: UrlUploadSectionProps) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">Provide image URL</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="image-url-input"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
>
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
id="image-url-input"
|
||||
name="image_url"
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => onUrlChange(e.target.value)}
|
||||
className="w-full rounded-md border-slate-300 text-sm"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Enter the direct URL to an image
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload Options Component
|
||||
interface UploadOptionsProps {
|
||||
imageUrl: string;
|
||||
isFileSelected: boolean;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileChange: (file: File | null) => Promise<void>;
|
||||
onUrlChange: (url: string) => void;
|
||||
}
|
||||
|
||||
function UploadOptions({
|
||||
imageUrl,
|
||||
isFileSelected,
|
||||
fileInputRef,
|
||||
onFileChange,
|
||||
onUrlChange,
|
||||
}: UploadOptionsProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-between gap-4 sm:flex-row ${
|
||||
isFileSelected ? 'hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<FileUploadSection
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={onFileChange}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">or</h3>
|
||||
</div>
|
||||
<UrlUploadSection imageUrl={imageUrl} onUrlChange={onUrlChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Submit Button Component
|
||||
function SubmitButton() {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search for Similar Images
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VisualSearchForm({
|
||||
actionUrl,
|
||||
csrfToken,
|
||||
}: VisualSearchFormProps) {
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageState, setImageState] = useState<ImageState | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [isDragOver, setIsDragOver] = useState<boolean>(false);
|
||||
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||
@@ -49,10 +419,18 @@ export default function VisualSearchForm({
|
||||
navigator.maxTouchPoints && navigator.maxTouchPoints > 2;
|
||||
return userAgent || touchPoints;
|
||||
};
|
||||
|
||||
setIsMobile(detectMobile());
|
||||
}, []);
|
||||
|
||||
// Cleanup object URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageState?.previewUrl) {
|
||||
URL.revokeObjectURL(imageState.previewUrl);
|
||||
}
|
||||
};
|
||||
}, [imageState?.previewUrl]);
|
||||
|
||||
// Show feedback message with auto-dismiss
|
||||
const showFeedback = useCallback(
|
||||
(text: string, type: FeedbackMessage['type']) => {
|
||||
@@ -62,9 +440,24 @@ export default function VisualSearchForm({
|
||||
[],
|
||||
);
|
||||
|
||||
const clearFeedback = useCallback(() => {
|
||||
setFeedbackMessage(null);
|
||||
}, []);
|
||||
|
||||
// Clear selected image
|
||||
const clearImage = useCallback(() => {
|
||||
if (imageState?.previewUrl) {
|
||||
URL.revokeObjectURL(imageState.previewUrl);
|
||||
}
|
||||
setImageState(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [imageState?.previewUrl]);
|
||||
|
||||
// Handle image file selection
|
||||
const handleImageFile = useCallback(
|
||||
(file: File) => {
|
||||
async (file: File) => {
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
showFeedback(
|
||||
'Please select a valid image file (JPG, PNG, GIF, WebP)',
|
||||
@@ -73,19 +466,64 @@ export default function VisualSearchForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
fileInputRef.current!.files = dataTransfer.files;
|
||||
// Clean up previous preview URL
|
||||
if (imageState?.previewUrl) {
|
||||
URL.revokeObjectURL(imageState.previewUrl);
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
const fileName = file.name || 'Pasted image';
|
||||
showFeedback(`Selected: ${fileName}`, 'success');
|
||||
// Show processing message for large files
|
||||
const originalSize = file.size;
|
||||
const isLargeFile = originalSize > 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Visual feedback
|
||||
setIsDragOver(true);
|
||||
setTimeout(() => setIsDragOver(false), 1000);
|
||||
if (isLargeFile) {
|
||||
showFeedback('Processing large image...', 'warning');
|
||||
}
|
||||
|
||||
try {
|
||||
// Resize image if needed
|
||||
const processedFile = await resizeImageIfNeeded(file);
|
||||
clearFeedback();
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(processedFile);
|
||||
fileInputRef.current!.files = dataTransfer.files;
|
||||
|
||||
// Track original size if image was resized
|
||||
const wasResized = processedFile.size < originalSize;
|
||||
|
||||
// Create preview URL for the thumbnail
|
||||
const previewUrl = URL.createObjectURL(processedFile);
|
||||
|
||||
// Set all image state at once
|
||||
setImageState({
|
||||
file: processedFile,
|
||||
previewUrl,
|
||||
originalFileSize: wasResized ? originalSize : null,
|
||||
});
|
||||
|
||||
// Visual feedback
|
||||
setIsDragOver(true);
|
||||
setTimeout(() => setIsDragOver(false), 1000);
|
||||
} catch (error) {
|
||||
showFeedback(
|
||||
'Error processing image. Please try another file.',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
},
|
||||
[showFeedback],
|
||||
[showFeedback, imageState?.previewUrl],
|
||||
);
|
||||
|
||||
// File change handler
|
||||
const handleFileChange = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (file) {
|
||||
await handleImageFile(file);
|
||||
} else {
|
||||
clearImage();
|
||||
}
|
||||
},
|
||||
[handleImageFile, clearImage],
|
||||
);
|
||||
|
||||
// Drag and drop handlers
|
||||
@@ -107,7 +545,7 @@ export default function VisualSearchForm({
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
@@ -116,7 +554,7 @@ export default function VisualSearchForm({
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.match('image.*')) {
|
||||
handleImageFile(file);
|
||||
await handleImageFile(file);
|
||||
} else {
|
||||
showFeedback(`Please drop an image file (got ${file.type})`, 'error');
|
||||
}
|
||||
@@ -140,7 +578,7 @@ export default function VisualSearchForm({
|
||||
const file = new File([blob], 'clipboard-image.png', {
|
||||
type: blob.type,
|
||||
});
|
||||
handleImageFile(file);
|
||||
await handleImageFile(file);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +596,7 @@ export default function VisualSearchForm({
|
||||
|
||||
// Paste event handler
|
||||
const handlePaste = useCallback(
|
||||
(e: ClipboardEvent) => {
|
||||
async (e: ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const clipboardItems = e.clipboardData?.items;
|
||||
@@ -176,7 +614,7 @@ export default function VisualSearchForm({
|
||||
}
|
||||
|
||||
if (imageFile) {
|
||||
handleImageFile(imageFile);
|
||||
await handleImageFile(imageFile);
|
||||
} else {
|
||||
showFeedback(
|
||||
'No image found in clipboard. Copy an image first, then paste here.',
|
||||
@@ -224,8 +662,7 @@ export default function VisualSearchForm({
|
||||
|
||||
// Set up paste event listeners
|
||||
useEffect(() => {
|
||||
const pasteHandler = (e: ClipboardEvent) => handlePaste(e);
|
||||
|
||||
const pasteHandler = async (e: ClipboardEvent) => await handlePaste(e);
|
||||
document.addEventListener('paste', pasteHandler);
|
||||
return () => document.removeEventListener('paste', pasteHandler);
|
||||
}, [handlePaste]);
|
||||
@@ -248,20 +685,7 @@ export default function VisualSearchForm({
|
||||
return () => {
|
||||
dragDropElement.removeEventListener('touchstart', handleTouchStart);
|
||||
};
|
||||
}, [isMobile]);
|
||||
|
||||
const getFeedbackClassName = (type: FeedbackMessage['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'text-green-600';
|
||||
case 'error':
|
||||
return 'text-red-600';
|
||||
case 'warning':
|
||||
return 'text-amber-600';
|
||||
default:
|
||||
return 'text-slate-600';
|
||||
}
|
||||
};
|
||||
}, [isMobile, dragDropRef, pasteInputRef]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden border border-slate-300 bg-white shadow-sm sm:rounded-lg">
|
||||
@@ -273,134 +697,35 @@ export default function VisualSearchForm({
|
||||
className="flex flex-col gap-4 p-4 sm:p-6"
|
||||
>
|
||||
<input type="hidden" name="authenticity_token" value={csrfToken} />
|
||||
<div
|
||||
ref={dragDropRef}
|
||||
onClick={handleDragDropClick}
|
||||
|
||||
<ImageDropZone
|
||||
imageState={imageState}
|
||||
isDragOver={isDragOver}
|
||||
isMobile={isMobile}
|
||||
feedbackMessage={feedbackMessage}
|
||||
onClearImage={clearImage}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleDragDropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={`relative mb-4 rounded-lg border-2 border-dashed p-6 text-center transition-colors duration-200 focus:border-blue-500 ${
|
||||
isDragOver ? 'border-blue-500 bg-blue-50' : 'border-slate-300'
|
||||
}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Hidden input for mobile paste functionality */}
|
||||
<input
|
||||
ref={pasteInputRef}
|
||||
type="text"
|
||||
className="pointer-events-none absolute opacity-0"
|
||||
style={{ left: '-9999px' }}
|
||||
autoComplete="off"
|
||||
onPaste={(e) => handlePaste(e.nativeEvent)}
|
||||
onContextMenu={(e) => isMobile && e.stopPropagation()}
|
||||
/>
|
||||
onPaste={handlePaste}
|
||||
pasteInputRef={pasteInputRef}
|
||||
dropZoneRef={dragDropRef}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-10 w-10 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="hidden font-medium text-slate-600 sm:block">
|
||||
Drag and drop an image here
|
||||
</p>
|
||||
<p className="block font-medium text-slate-600 sm:hidden">
|
||||
tap here to paste an image from the clipboard
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
or use one of the options below
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{feedbackMessage && (
|
||||
<p
|
||||
className={`text-sm ${getFeedbackClassName(feedbackMessage.type)} mt-2`}
|
||||
>
|
||||
{feedbackMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-4 sm:flex-row">
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">
|
||||
Upload an image
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="image-file-input"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
>
|
||||
Choose an image file
|
||||
</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="image-file-input"
|
||||
name="image_file"
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
className="w-full rounded-md border-slate-300 text-sm"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleImageFile(file);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Supported formats: JPG, PNG, GIF, WebP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">or</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-medium text-slate-500">
|
||||
Provide image URL
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor="image-url-input"
|
||||
className="text-sm font-medium text-slate-700"
|
||||
>
|
||||
Image URL
|
||||
</label>
|
||||
<input
|
||||
id="image-url-input"
|
||||
name="image_url"
|
||||
type="url"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
className="w-full rounded-md border-slate-300 text-sm"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Enter the direct URL to an image
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UploadOptions
|
||||
imageUrl={imageUrl}
|
||||
isFileSelected={!!imageState}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileChange={handleFileChange}
|
||||
onUrlChange={setImageUrl}
|
||||
/>
|
||||
|
||||
<div className="my-2 border-t border-slate-200"></div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Search for Similar Images
|
||||
</button>
|
||||
<SubmitButton />
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,87 +1,92 @@
|
||||
<div class="mx-auto flex flex-wrap justify-center mb-4 px-6">
|
||||
<div class="text-center w-full max-w-lg mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-1 text-gray-900">Visual Search Results</h1>
|
||||
<div class="flex justify-between items-center mt-2 mb-4">
|
||||
<span class="text-gray-600 text-sm font-medium">
|
||||
<% if @post_file_fingerprints.any? %>
|
||||
<span class="bg-blue-100 text-blue-700 px-2 py-0.5 rounded-md font-semibold">
|
||||
<%= @post_file_fingerprints.size %>
|
||||
</span> similar images found
|
||||
<% else %>
|
||||
No matches found
|
||||
<% end %>
|
||||
</span>
|
||||
<div class="mx-auto flex flex-wrap justify-center my-4 px-2 max-w-full">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-4 w-full sm:max-w-lg sm:mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Visual Search Results</h1>
|
||||
<%= link_to "New Search", visual_search_domain_posts_path, class: "text-white hover:text-white transition-colors duration-200 text-sm font-semibold bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md shadow-sm" %>
|
||||
</div>
|
||||
<% if @uploaded_image_data_uri.present? %>
|
||||
<div class="mx-auto mb-6 bg-white p-3 rounded-lg border border-gray-300 shadow-sm text-center">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-2">Your search image:</h3>
|
||||
<div class="flex flex-col mb-4 bg-white p-2 gap-2 rounded-lg border border-gray-300 shadow-sm text-center w-fit mx-auto">
|
||||
<% if @uploaded_image_data_uri.present? %>
|
||||
<div class="flex justify-center items-center">
|
||||
<img src="<%= @uploaded_image_data_uri %>" alt="Uploaded image" class="max-h-[180px] rounded-lg border border-gray-300 object-contain shadow-sm" />
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-gray-600 text-sm font-medium">Error creating thumbnail</div>
|
||||
<% end %>
|
||||
<div class="flex justify-center items-center text-sm font-medium gap-1">
|
||||
<% if @matches.any? %>
|
||||
<% if @good_matches.any? %>
|
||||
<span class="bg-blue-100 text-blue-700 px-2 py-0.5 rounded-md font-semibold">
|
||||
<%= @good_matches.size %>
|
||||
</span>
|
||||
<span class="text-gray-600"><%= "match".pluralize(@good_matches.size) %></span>
|
||||
<% if @bad_matches.any? %>
|
||||
<div class="border-l border-gray-300 h-6 mx-2"></div>
|
||||
<span class="bg-gray-100 text-gray-700 px-2 py-0.5 rounded-md font-semibold">
|
||||
<%= @matches.size %>
|
||||
</span>
|
||||
<span class="text-gray-600">total</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="bg-gray-100 text-gray-700 px-2 py-0.5 rounded-md font-semibold">
|
||||
<%= @matches.size %>
|
||||
</span>
|
||||
<span class="text-gray-600">results, no close matches</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
No results found
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @post_file_fingerprints.empty? %>
|
||||
<div class="bg-blue-50 text-blue-800 p-4 rounded-lg border border-blue-300 shadow-sm mx-auto max-w-lg">
|
||||
<p class="mb-1 font-medium">No similar images found</p>
|
||||
<p class="text-sm text-blue-700">Try adjusting the similarity threshold to a higher value.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @post_file_fingerprints.any? %>
|
||||
<div class="mx-2">
|
||||
<div class="flex flex-wrap gap-3 justify-center">
|
||||
<% @post_file_fingerprints.each do |post_file_fingerprint| %>
|
||||
<% post_file = post_file_fingerprint.fingerprint.post_file %>
|
||||
<% post = post_file.post %>
|
||||
<% similarity_percentage = post_file_fingerprint.similarity_percentage %>
|
||||
<div class="flex flex-col h-fit rounded-md border border-gray-300 bg-white shadow hover:shadow-md transition-shadow duration-300 overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 p-2 bg-gray-50 gap-2">
|
||||
<div class="flex items-center">
|
||||
<%= render "domain/posts/inline_postable_domain_link", post: post %>
|
||||
</div>
|
||||
<div class="<%= match_badge_classes(similarity_percentage) %> ml-auto rounded-full font-bold text-xs px-2 py-1 shadow-sm">
|
||||
<%= similarity_percentage %>%
|
||||
</div>
|
||||
</div>
|
||||
<% if thumbnail = thumbnail_for_post_path(post) %>
|
||||
<div class="flex items-center justify-center p-2 border-b border-gray-200 bg-white w-full">
|
||||
<%= link_to domain_post_path(post), class: "transition-transform duration-300 " do %>
|
||||
<%= image_tag thumbnail,
|
||||
<div class="flex flex-wrap gap-3 justify-center max-w-full">
|
||||
<% @matches.each do |post_file_fingerprint| %>
|
||||
<% post = post_file_fingerprint.fingerprint.post_file&.post %>
|
||||
<% similarity_percentage = post_file_fingerprint.similarity_percentage %>
|
||||
<div class="flex flex-col rounded-md border border-gray-300 bg-white shadow hover:shadow-md transition-shadow duration-300 overflow-hidden">
|
||||
<div class="flex justify-between items-center border-b border-gray-200 p-2 bg-gray-50 gap-2">
|
||||
<div class="flex items-center">
|
||||
<%= render "domain/posts/inline_postable_domain_link", post: post %>
|
||||
</div>
|
||||
<div class="<%= match_badge_classes(similarity_percentage) %> ml-auto rounded-full font-bold text-xs px-2 py-1 shadow-sm">
|
||||
<%= similarity_percentage %>%
|
||||
</div>
|
||||
</div>
|
||||
<% if thumbnail = thumbnail_for_post_path(post) %>
|
||||
<div class="flex items-center justify-center p-2 border-b border-gray-200 bg-white w-full">
|
||||
<%= link_to domain_post_path(post), class: "transition-transform duration-300 " do %>
|
||||
<%= image_tag thumbnail,
|
||||
class: "rounded-lg border max-h-[180px] max-w-[180px] border-gray-300 object-contain shadow-sm",
|
||||
alt: post.title.presence || "Untitled Post" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex flex-col justify-between">
|
||||
<h2 class="p-2 text-center text-base font-medium text-gray-800 truncate">
|
||||
<%= link_to post.title.presence || "Untitled Post", domain_post_path(post), class: "text-blue-700 hover:text-blue-800 transition-colors duration-200" %>
|
||||
</h2>
|
||||
<div class="px-2 pb-2 text-xs text-gray-600 mt-auto">
|
||||
<div class="flex justify-between items-center flex-wrap gap-1">
|
||||
<span class="flex items-center gap-0.5">
|
||||
<% if creator = post.primary_creator_for_view %>
|
||||
<span class="text-gray-500 italic">by</span>
|
||||
<%= render "domain/has_description_html/inline_link_domain_user", user: creator, visual_style: "sky-link", size: "small" %>
|
||||
<% elsif creator = post.primary_creator_name_fallback_for_view %>
|
||||
<span class="text-gray-500 italic">by</span> <%= creator %>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
<% if post.created_at %>
|
||||
<span class="text-gray-500"><i class="far fa-clock mr-1"></i></span> <%=
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex flex-col justify-between">
|
||||
<h2 class="p-2 text-center text-base font-medium text-gray-800 truncate overflow-hidden">
|
||||
<%= link_to post.title.presence || "Untitled Post", domain_post_path(post), class: "text-blue-700 hover:text-blue-800 transition-colors duration-200" %>
|
||||
</h2>
|
||||
<div class="px-2 pb-2 text-xs text-gray-600 mt-auto">
|
||||
<div class="flex justify-between items-center flex-wrap gap-1">
|
||||
<span class="flex items-center gap-0.5">
|
||||
<% if creator = post.primary_creator_for_view %>
|
||||
<span class="text-gray-500 italic">by</span>
|
||||
<%= render "domain/has_description_html/inline_link_domain_user", user: creator, visual_style: "sky-link", size: "small" %>
|
||||
<% elsif creator = post.primary_creator_name_fallback_for_view %>
|
||||
<span class="text-gray-500 italic">by</span> <%= creator %>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="font-medium">
|
||||
<% if post.created_at %>
|
||||
<span class="text-gray-500"><i class="far fa-clock mr-1"></i></span> <%=
|
||||
post.posted_at.present? ?
|
||||
time_ago_in_words(post.posted_at) :
|
||||
time_ago_in_words(post.created_at)
|
||||
%> ago
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="mx-auto mt-4 w-full max-w-2xl flex flex-col gap-4 pb-4">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-semibold mb-1">Visual Search</h1>
|
||||
<h1 class="text-2xl font-semibold mb-1">Visual Search?</h1>
|
||||
<p class="text-slate-600">Find images similar to the one you provide.</p>
|
||||
</div>
|
||||
<% if flash[:error] %>
|
||||
|
||||
@@ -171,7 +171,7 @@ RSpec.describe Domain::PostsController, type: :controller do
|
||||
expect(assigns(:uploaded_detail_hash_value)).to eq(
|
||||
mock_detail_hash_value,
|
||||
)
|
||||
expect(assigns(:post_file_fingerprints)).to eq(mock_fingerprints.to_a)
|
||||
expect(assigns(:matches)).to eq(mock_fingerprints.to_a)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user