updates to visual search form, show close matches if they exist

This commit is contained in:
Dylan Knutson
2025-07-30 19:36:13 +00:00
parent 1bab697a43
commit d2432ae02e
5 changed files with 564 additions and 233 deletions

View File

@@ -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

View 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>
);

View File

@@ -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>

View File

@@ -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] %>

View File

@@ -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