2 Commits

Author SHA1 Message Date
Dylan Knutson
d2432ae02e updates to visual search form, show close matches if they exist 2025-07-30 19:36:13 +00:00
Dylan Knutson
1bab697a43 visual search form react component 2025-07-30 17:26:54 +00:00
8 changed files with 837 additions and 337 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

@@ -522,6 +522,15 @@ module Domain::PostsHelper
end
end
sig { returns(T::Hash[Symbol, T.untyped]) }
def props_for_visual_search_form
{
actionUrl:
Rails.application.routes.url_helpers.visual_results_domain_posts_path,
csrfToken: form_authenticity_token,
}
end
private
sig { params(url: String).returns(T.nilable(String)) }

View File

@@ -0,0 +1,732 @@
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface VisualSearchFormProps {
actionUrl: string;
csrfToken: string;
}
interface FeedbackMessage {
text: string;
type: 'success' | 'error' | 'warning';
}
interface ImageState {
file: File;
previewUrl: string;
originalFileSize: number | null;
}
const ACCEPTED_IMAGE_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
];
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 [imageState, setImageState] = useState<ImageState | null>(null);
const [imageUrl, setImageUrl] = useState<string>('');
const [isDragOver, setIsDragOver] = useState<boolean>(false);
const [isMobile, setIsMobile] = useState<boolean>(false);
const [feedbackMessage, setFeedbackMessage] =
useState<FeedbackMessage | null>(null);
const dragDropRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const pasteInputRef = useRef<HTMLInputElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Detect mobile device
useEffect(() => {
const detectMobile = () => {
const userAgent =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
const touchPoints =
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']) => {
setFeedbackMessage({ text, type });
setTimeout(() => setFeedbackMessage(null), 5000);
},
[],
);
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(
async (file: File) => {
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
showFeedback(
'Please select a valid image file (JPG, PNG, GIF, WebP)',
'error',
);
return;
}
// Clean up previous preview URL
if (imageState?.previewUrl) {
URL.revokeObjectURL(imageState.previewUrl);
}
// Show processing message for large files
const originalSize = file.size;
const isLargeFile = originalSize > 2 * 1024 * 1024; // 2MB
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, 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
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.match('image.*')) {
await handleImageFile(file);
} else {
showFeedback(`Please drop an image file (got ${file.type})`, 'error');
}
}
},
[handleImageFile, showFeedback],
);
// Modern Clipboard API handler
const tryClipboardAPIRead = useCallback(async (): Promise<boolean> => {
try {
if (!navigator.clipboard || !navigator.clipboard.read) {
return false;
}
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type.startsWith('image/')) {
const blob = await clipboardItem.getType(type);
const file = new File([blob], 'clipboard-image.png', {
type: blob.type,
});
await handleImageFile(file);
return true;
}
}
}
showFeedback(
'No image found in clipboard. Copy an image first, then try again.',
'warning',
);
return false;
} catch (err) {
return false;
}
}, [handleImageFile, showFeedback]);
// Paste event handler
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
e.preventDefault();
const clipboardItems = e.clipboardData?.items;
if (!clipboardItems) return;
let imageFile: File | null = null;
// Look for image data in clipboard
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.indexOf('image') !== -1) {
imageFile = item.getAsFile();
break;
}
}
if (imageFile) {
await handleImageFile(imageFile);
} else {
showFeedback(
'No image found in clipboard. Copy an image first, then paste here.',
'warning',
);
}
},
[handleImageFile, showFeedback],
);
// Mobile paste instruction
const showMobilePasteInstruction = useCallback(() => {
showFeedback('Tap this area and select "Paste" from the menu', 'warning');
}, [showFeedback]);
// Click handler for drag-drop area
const handleDragDropClick = useCallback(async () => {
if (isMobile) {
pasteInputRef.current?.focus();
const success = await tryClipboardAPIRead();
if (!success) {
showMobilePasteInstruction();
}
} else {
dragDropRef.current?.focus();
}
}, [isMobile, tryClipboardAPIRead, showMobilePasteInstruction]);
// Keyboard event handler
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
formRef.current?.submit();
} else if (e.key === ' ') {
e.preventDefault();
if (isMobile) {
pasteInputRef.current?.focus();
showMobilePasteInstruction();
}
}
},
[isMobile, showMobilePasteInstruction],
);
// Set up paste event listeners
useEffect(() => {
const pasteHandler = async (e: ClipboardEvent) => await handlePaste(e);
document.addEventListener('paste', pasteHandler);
return () => document.removeEventListener('paste', pasteHandler);
}, [handlePaste]);
// Mobile touch support
useEffect(() => {
if (!isMobile || !dragDropRef.current) return;
const handleTouchStart = () => {
setTimeout(() => {
pasteInputRef.current?.focus();
}, 100);
};
const dragDropElement = dragDropRef.current;
dragDropElement.addEventListener('touchstart', handleTouchStart, {
passive: true,
});
return () => {
dragDropElement.removeEventListener('touchstart', handleTouchStart);
};
}, [isMobile, dragDropRef, pasteInputRef]);
return (
<div className="overflow-hidden border border-slate-300 bg-white shadow-sm sm:rounded-lg">
<form
ref={formRef}
method="post"
action={actionUrl}
encType="multipart/form-data"
className="flex flex-col gap-4 p-4 sm:p-6"
>
<input type="hidden" name="authenticity_token" value={csrfToken} />
<ImageDropZone
imageState={imageState}
isDragOver={isDragOver}
isMobile={isMobile}
feedbackMessage={feedbackMessage}
onClearImage={clearImage}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleDragDropClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
pasteInputRef={pasteInputRef}
dropZoneRef={dragDropRef}
/>
<UploadOptions
imageUrl={imageUrl}
isFileSelected={!!imageState}
fileInputRef={fileInputRef}
onFileChange={handleFileChange}
onUrlChange={setImageUrl}
/>
<div className="my-2 border-t border-slate-200"></div>
<SubmitButton />
</form>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import { TrackedObjectsChart } from '../bundles/Main/components/TrackedObjectsCh
import { initCollapsibleSections } from '../bundles/UI/collapsibleSections';
import { IpAddressInput } from '../bundles/UI/components';
import { StatsPage } from '../bundles/Main/components/StatsPage';
import VisualSearchForm from '../bundles/Main/components/VisualSearchForm';
import { initUserMenu } from '../bundles/UI/userMenu';
// This is how react_on_rails can see the components in the browser.
@@ -17,6 +18,7 @@ ReactOnRails.register({
TrackedObjectsChart,
IpAddressInput,
StatsPage,
VisualSearchForm,
});
// Initialize UI components

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] %>
@@ -8,261 +8,8 @@
<%= flash[:error] %>
</div>
<% end %>
<div class="bg-white rounded-lg border border-slate-300 shadow-sm overflow-hidden">
<div class="p-4 sm:p-6">
<%= form_with url: visual_results_domain_posts_path, method: :post, multipart: true, class: "flex flex-col gap-4" do |form| %>
<div id="drag-drop-area" class="border-2 border-dashed border-slate-300 rounded-lg p-6 text-center mb-4 transition-colors duration-200 cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 relative" tabindex="0">
<!-- Hidden input for mobile paste functionality -->
<input type="text" id="paste-input" class="absolute opacity-0 pointer-events-none" style="left: -9999px;" autocomplete="off">
<div class="flex flex-col items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="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 class="text-slate-600 font-medium hidden sm:block">Drag and drop an image here</p>
<p class="text-slate-600 font-medium sm:hidden block">tap here to paste an image from the clipboard</p>
<p class="text-xs text-slate-500">or use one of the options below</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-medium">Option 1: Upload an image</h3>
<div class="flex flex-col gap-1">
<%= form.label :image_file, "Choose an image file", class: "text-sm font-medium text-slate-700" %>
<%= form.file_field :image_file, accept: "image/png,image/jpeg,image/jpg,image/gif,image/webp", class: "w-full rounded-md border-slate-300 text-sm", id: "image-file-input" %>
<p class="text-xs text-slate-500">Supported formats: JPG, PNG, GIF, WebP</p>
</div>
</div>
<div class="flex flex-col gap-2">
<h3 class="text-lg font-medium">Option 2: Provide image URL</h3>
<div class="flex flex-col gap-1">
<%= form.label :image_url, "Image URL", class: "text-sm font-medium text-slate-700" %>
<%= form.url_field :image_url, class: "w-full rounded-md border-slate-300 text-sm", placeholder: "https://example.com/image.jpg" %>
<p class="text-xs text-slate-500">Enter the direct URL to an image</p>
</div>
</div>
</div>
<div class="border-t border-slate-200 my-4"></div>
<div class="mt-4">
<%= form.submit "Search for Similar Images", class: "w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md text-sm font-medium transition-colors" %>
</div>
<% end %>
</div>
</div>
<%= react_component("VisualSearchForm", {
prerender: false,
props: props_for_visual_search_form,
}) %>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const dragDropArea = document.getElementById('drag-drop-area');
const fileInput = document.getElementById('image-file-input');
const pasteInput = document.getElementById('paste-input');
// Detect if user is on mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
// Prevent default drag behaviors
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dragDropArea.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
// Highlight drop area when item is dragged over it
['dragenter', 'dragover'].forEach(eventName => {
dragDropArea.addEventListener(eventName, highlight, false);
});
// Remove highlight when item is dragged out or dropped
['dragleave', 'drop'].forEach(eventName => {
dragDropArea.addEventListener(eventName, unhighlight, false);
});
// Handle dropped files
dragDropArea.addEventListener('drop', handleDrop, false);
// Handle click/tap on drag-drop area
dragDropArea.addEventListener('click', async function() {
if (isMobile) {
// On mobile, focus the hidden input to enable paste context menu
pasteInput.focus();
// Try modern Clipboard API first if available
if (navigator.clipboard && navigator.clipboard.read) {
try {
await tryClipboardAPIRead();
} catch (err) {
// Fallback to showing instruction for manual paste
showMobilePasteInstruction();
}
} else {
showMobilePasteInstruction();
}
} else {
// On desktop, focus the main area for keyboard paste
dragDropArea.focus();
}
});
// Listen for paste events
pasteInput.addEventListener('paste', handlePaste, false);
dragDropArea.addEventListener('paste', handlePaste, false);
document.addEventListener('paste', handlePaste, false);
// Handle keyboard events for accessibility
dragDropArea.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isMobile) {
pasteInput.focus();
showMobilePasteInstruction();
}
}
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function highlight() {
dragDropArea.classList.add('border-blue-500');
dragDropArea.classList.add('bg-blue-50');
}
function unhighlight() {
dragDropArea.classList.remove('border-blue-500');
dragDropArea.classList.remove('bg-blue-50');
}
function showMobilePasteInstruction() {
// Create a temporary instruction message for mobile
const instruction = document.createElement('p');
instruction.textContent = 'Long press this area and select "Paste" from the menu, or use your browser\'s paste option';
instruction.className = 'text-sm text-blue-600 mt-2 paste-instruction';
// Remove any previous instruction
const previousInstruction = dragDropArea.querySelector('.paste-instruction');
if (previousInstruction) {
previousInstruction.remove();
}
dragDropArea.appendChild(instruction);
// Remove instruction after 5 seconds (longer for mobile users to read)
setTimeout(() => {
if (instruction.parentNode) {
instruction.remove();
}
}, 5000);
}
async function tryClipboardAPIRead() {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
for (const type of clipboardItem.types) {
if (type.startsWith('image/')) {
const blob = await clipboardItem.getType(type);
const file = new File([blob], 'clipboard-image.png', { type: blob.type });
handleImageFile(file);
return; // Successfully handled
}
}
}
// No image found
showFeedbackMessage('No image found in clipboard. Copy an image first, then try again.', 'text-amber-600');
} catch (err) {
// API failed, throw to trigger fallback
throw err;
}
}
function handlePaste(e) {
e.preventDefault();
const clipboardItems = e.clipboardData.items;
let imageFile = null;
// Look for image data in clipboard
for (let i = 0; i < clipboardItems.length; i++) {
const item = clipboardItems[i];
if (item.type.indexOf('image') !== -1) {
imageFile = item.getAsFile();
break;
}
}
if (imageFile) {
handleImageFile(imageFile);
} else {
// Show message if no image found in clipboard
showFeedbackMessage('No image found in clipboard. Copy an image first, then paste here.', 'text-amber-600');
}
}
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length) {
const file = files[0];
if (file.type.match('image.*')) {
handleImageFile(file);
} else {
showFeedbackMessage('Please drop an image file.', 'text-red-600');
}
}
}
function handleImageFile(file) {
// Create a new FileList-like object
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
fileInput.files = dataTransfer.files;
// Show success feedback
const fileName = file.name || 'Pasted image';
showFeedbackMessage(`Selected: ${fileName}`, 'text-green-600');
// Add visual feedback to the drag-drop area
highlight();
setTimeout(unhighlight, 1000);
}
function showFeedbackMessage(message, className) {
// Create feedback message
const feedback = document.createElement('p');
feedback.textContent = message;
feedback.className = `text-sm ${className} mt-2 feedback-message`;
// Remove any previous feedback
const previousFeedback = dragDropArea.querySelector('.feedback-message');
if (previousFeedback) {
previousFeedback.remove();
}
dragDropArea.appendChild(feedback);
// Remove feedback after 5 seconds
setTimeout(() => {
if (feedback.parentNode) {
feedback.remove();
}
}, 5000);
}
// Add additional mobile support
if (isMobile) {
// Enable long press context menu on the hidden input
pasteInput.addEventListener('contextmenu', function(e) {
// Allow context menu to show (which includes paste option)
e.stopPropagation();
});
// Handle touch start to prepare for paste
dragDropArea.addEventListener('touchstart', function(e) {
// Focus the hidden input to enable paste functionality
setTimeout(() => {
pasteInput.focus();
}, 100);
}, { passive: true });
}
});
</script>

View File

@@ -22,6 +22,10 @@ module HelpersInterface
def raw(value)
end
sig { returns(String) }
def form_authenticity_token
end
sig { params(param: T.untyped, options: T.untyped).returns(String) }
def polymorphic_path(param, options = {})
end

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