Compare commits
2 Commits
0ecada567d
...
d2432ae02e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2432ae02e | ||
|
|
1bab697a43 |
@@ -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
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
732
app/javascript/bundles/Main/components/VisualSearchForm.tsx
Normal file
732
app/javascript/bundles/Main/components/VisualSearchForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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] %>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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