Allow image pasting into task field (#883)
* **Changes** - Made `ImageUploadSection` ref-aware with an `addFiles` imperative handle and auto-expansion when images appear, enabling non-UI triggers to reuse its validation/upload flow (frontend/src/components/ui/ImageUploadSection.tsx:36-348). - Captured clipboard image blobs in the shared textarea component and exposed them via a new `onPasteFiles` hook for upstream consumers (frontend/src/components/ui/file-search-textarea.tsx:111-137). - Task creation dialog now routes pasted images straight into the uploader through the new ref, keeping the description markdown in sync (frontend/src/components/dialogs/tasks/TaskFormDialog.tsx:102-357). - Follow-up editor keeps the image panel mounted but hidden, reveals it on paste, and forwards the new prop through its wrapper so task attempts accept clipboard images (frontend/src/components/tasks/TaskFollowUpSection.tsx:89-325; frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx:13-47). **Tests** - `npm run check` (frontend) - `pnpm run check` (timed out after backend cargo check kicked off; see log) **Next Steps** 1. Re-run `pnpm run check` or `npm run backend:check` when you can let Cargo finish to confirm backend continues to build. 2. Manually paste a few image formats/sizes into the task form and follow-up editor to confirm UX and markdown output look right. * Update frontend/src/components/ui/ImageUploadSection.tsx Co-authored-by: Gabriel Gordon-Hall <gabriel@bloop.ai> * fmt --------- Co-authored-by: Greg Burch <gburch@styleseat.com> Co-authored-by: Gabriel Gordon-Hall <gabriel@bloop.ai> Co-authored-by: Gabriel Gordon-Hall <ggordonhall@gmail.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Globe2, Settings2, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
|
||||
import {
|
||||
ImageUploadSection,
|
||||
type ImageUploadSectionHandle,
|
||||
} from '@/components/ui/ImageUploadSection';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -82,6 +85,7 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
useState<ExecutorProfileId | null>(null);
|
||||
const [quickstartExpanded, setQuickstartExpanded] =
|
||||
useState<boolean>(false);
|
||||
const imageUploadRef = useRef<ImageUploadSectionHandle>(null);
|
||||
|
||||
const isEditMode = Boolean(task);
|
||||
|
||||
@@ -290,6 +294,11 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handlePasteImages = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
void imageUploadRef.current?.addFiles(files);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!title.trim() || !projectId) return;
|
||||
|
||||
@@ -492,10 +501,12 @@ export const TaskFormDialog = NiceModal.create<TaskFormDialogProps>(
|
||||
isEditMode ? handleSubmit : handleCreateAndStart
|
||||
}
|
||||
onCommandShiftEnter={handleSubmit}
|
||||
onPasteFiles={handlePasteImages}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ImageUploadSection
|
||||
ref={imageUploadRef}
|
||||
images={images}
|
||||
onImagesChange={handleImagesChange}
|
||||
onUpload={imagesApi.upload}
|
||||
|
||||
@@ -6,10 +6,13 @@ import {
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ImageUploadSection } from '@/components/ui/ImageUploadSection';
|
||||
import {
|
||||
ImageUploadSection,
|
||||
type ImageUploadSectionHandle,
|
||||
} from '@/components/ui/ImageUploadSection';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
//
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { imagesApi } from '@/lib/api.ts';
|
||||
import type { TaskWithAttemptStatus } from 'shared/types';
|
||||
import { useBranchStatus } from '@/hooks';
|
||||
@@ -106,6 +109,13 @@ export function TaskFollowUpSection({
|
||||
|
||||
// Presentation-only: show/hide image upload panel
|
||||
const [showImageUpload, setShowImageUpload] = useState(false);
|
||||
const imageUploadRef = useRef<ImageUploadSectionHandle>(null);
|
||||
|
||||
const handlePasteImages = useCallback((files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setShowImageUpload(true);
|
||||
void imageUploadRef.current?.addFiles(files);
|
||||
}, []);
|
||||
|
||||
// Variant selection (with keyboard cycling)
|
||||
const { selectedVariant, setSelectedVariant, currentProfile } =
|
||||
@@ -288,25 +298,29 @@ export function TaskFollowUpSection({
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{showImageUpload && (
|
||||
<div className="mb-2">
|
||||
<ImageUploadSection
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
|
||||
onDelete={imagesApi.delete}
|
||||
onImageUploaded={(image) => {
|
||||
handleImageUploaded(image);
|
||||
setFollowUpMessage((prev) =>
|
||||
appendImageMarkdown(prev, image)
|
||||
);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
collapsible={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2',
|
||||
!showImageUpload && images.length === 0 && 'hidden'
|
||||
)}
|
||||
>
|
||||
<ImageUploadSection
|
||||
ref={imageUploadRef}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onUpload={(file) => imagesApi.uploadForTask(task.id, file)}
|
||||
onDelete={imagesApi.delete}
|
||||
onImageUploaded={(image) => {
|
||||
handleImageUploaded(image);
|
||||
setFollowUpMessage((prev) =>
|
||||
appendImageMarkdown(prev, image)
|
||||
);
|
||||
}}
|
||||
disabled={!isEditable}
|
||||
collapsible={false}
|
||||
defaultExpanded={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Review comments preview */}
|
||||
{reviewMarkdown && (
|
||||
@@ -352,6 +366,7 @@ export function TaskFollowUpSection({
|
||||
showLoadingOverlay={isUnqueuing || !isDraftLoaded}
|
||||
onCommandEnter={onSendFollowUp}
|
||||
onCommandShiftEnter={onSendFollowUp}
|
||||
onPasteFiles={handlePasteImages}
|
||||
/>
|
||||
<FollowUpStatusRow
|
||||
status={{
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
showLoadingOverlay: boolean;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onPasteFiles?: (files: File[]) => void;
|
||||
textareaClassName?: string;
|
||||
};
|
||||
|
||||
@@ -25,6 +26,7 @@ export function FollowUpEditorCard({
|
||||
showLoadingOverlay,
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
onPasteFiles,
|
||||
textareaClassName,
|
||||
}: Props) {
|
||||
const { projectId } = useProject();
|
||||
@@ -42,6 +44,7 @@ export function FollowUpEditorCard({
|
||||
maxRows={6}
|
||||
onCommandEnter={onCommandEnter}
|
||||
onCommandShiftEnter={onCommandShiftEnter}
|
||||
onPasteFiles={onPasteFiles}
|
||||
/>
|
||||
{showLoadingOverlay && (
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/60">
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
import {
|
||||
X,
|
||||
Image as ImageIcon,
|
||||
@@ -26,279 +33,324 @@ interface ImageUploadSectionProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageUploadSection({
|
||||
images,
|
||||
onImagesChange,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onImageUploaded,
|
||||
isUploading = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
collapsible = true,
|
||||
defaultExpanded = false,
|
||||
className,
|
||||
}: ImageUploadSectionProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
defaultExpanded || images.length > 0
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(new Set());
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
export interface ImageUploadSectionHandle {
|
||||
addFiles: (files: FileList | File[] | null) => Promise<void>;
|
||||
}
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || disabled) return;
|
||||
const MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
const VALID_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
|
||||
setErrorMessage(null);
|
||||
export const ImageUploadSection = forwardRef<
|
||||
ImageUploadSectionHandle,
|
||||
ImageUploadSectionProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
images,
|
||||
onImagesChange,
|
||||
onUpload,
|
||||
onDelete,
|
||||
onImageUploaded,
|
||||
isUploading = false,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
collapsible = true,
|
||||
defaultExpanded = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
defaultExpanded || images.length > 0
|
||||
);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const latestImagesRef = useRef(images);
|
||||
|
||||
const MAX_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
const VALID_TYPES = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
'image/svg+xml',
|
||||
];
|
||||
useEffect(() => {
|
||||
latestImagesRef.current = images;
|
||||
}, [images]);
|
||||
|
||||
const invalidFiles: string[] = [];
|
||||
const oversizedFiles: string[] = [];
|
||||
const validFiles: File[] = [];
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
if (!VALID_TYPES.includes(file.type.toLowerCase())) {
|
||||
invalidFiles.push(file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
oversizedFiles.push(
|
||||
`${file.name} (${(file.size / 1048576).toFixed(1)} MB)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0 || oversizedFiles.length > 0) {
|
||||
const errors: string[] = [];
|
||||
if (invalidFiles.length > 0) {
|
||||
errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`);
|
||||
}
|
||||
if (oversizedFiles.length > 0) {
|
||||
errors.push(
|
||||
`Files too large (max 20 MB): ${oversizedFiles.join(', ')}`
|
||||
);
|
||||
}
|
||||
setErrorMessage(errors.join('. '));
|
||||
useEffect(() => {
|
||||
if (collapsible && images.length > 0 && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [collapsible, images.length]);
|
||||
|
||||
for (const file of validFiles) {
|
||||
const tempId = `uploading-${Date.now()}-${file.name}`;
|
||||
setUploadingFiles((prev) => new Set(prev).add(tempId));
|
||||
const handleFiles = useCallback(
|
||||
async (filesInput: FileList | File[] | null) => {
|
||||
if (!filesInput || disabled || readOnly) return;
|
||||
|
||||
try {
|
||||
const uploadedImage = await onUpload(file);
|
||||
const files = Array.isArray(filesInput)
|
||||
? filesInput
|
||||
: Array.from(filesInput);
|
||||
|
||||
// Call custom upload callback if provided, otherwise use default behavior
|
||||
if (onImageUploaded) {
|
||||
onImageUploaded(uploadedImage);
|
||||
} else {
|
||||
onImagesChange([...images, uploadedImage]);
|
||||
setErrorMessage(null);
|
||||
|
||||
const invalidFiles: string[] = [];
|
||||
const oversizedFiles: string[] = [];
|
||||
const validFiles: File[] = [];
|
||||
|
||||
files.forEach((file) => {
|
||||
if (!VALID_TYPES.includes(file.type.toLowerCase())) {
|
||||
invalidFiles.push(file.name);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to upload image:', error);
|
||||
const message =
|
||||
error.message || 'Failed to upload image. Please try again.';
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setUploadingFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(tempId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[images, onImagesChange, onImageUploaded, onUpload, disabled]
|
||||
);
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
oversizedFiles.push(
|
||||
`${file.name} (${(file.size / 1048576).toFixed(1)} MB)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
validFiles.push(file);
|
||||
});
|
||||
|
||||
if (invalidFiles.length > 0 || oversizedFiles.length > 0) {
|
||||
const errors: string[] = [];
|
||||
if (invalidFiles.length > 0) {
|
||||
errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`);
|
||||
}
|
||||
if (oversizedFiles.length > 0) {
|
||||
errors.push(
|
||||
`Files too large (max 20 MB): ${oversizedFiles.join(', ')}`
|
||||
);
|
||||
}
|
||||
setErrorMessage(errors.join('. '));
|
||||
}
|
||||
|
||||
for (const file of validFiles) {
|
||||
const tempId = `uploading-${Date.now()}-${file.name}`;
|
||||
setUploadingFiles((prev) => new Set(prev).add(tempId));
|
||||
|
||||
try {
|
||||
const uploadedImage = await onUpload(file);
|
||||
|
||||
// Call custom upload callback if provided, otherwise use default behavior
|
||||
if (onImageUploaded) {
|
||||
onImageUploaded(uploadedImage);
|
||||
} else {
|
||||
const nextImages = [...latestImagesRef.current, uploadedImage];
|
||||
latestImagesRef.current = nextImages;
|
||||
onImagesChange(nextImages);
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to upload image:', error);
|
||||
const message =
|
||||
error.message || 'Failed to upload image. Please try again.';
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setUploadingFiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(tempId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, readOnly, onUpload, onImageUploaded, onImagesChange]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
addFiles: async (files: FileList | File[] | null) => {
|
||||
await handleFiles(files);
|
||||
},
|
||||
}),
|
||||
[handleFiles]
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
void handleFiles(e.dataTransfer.files);
|
||||
},
|
||||
[handleFiles]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
},
|
||||
[handleFileSelect]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoveImage = useCallback(
|
||||
async (imageId: string) => {
|
||||
if (onDelete) {
|
||||
try {
|
||||
await onDelete(imageId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
const handleRemoveImage = useCallback(
|
||||
async (imageId: string) => {
|
||||
if (onDelete) {
|
||||
try {
|
||||
await onDelete(imageId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete image:', error);
|
||||
}
|
||||
}
|
||||
onImagesChange(images.filter((img) => img.id !== imageId));
|
||||
},
|
||||
[images, onImagesChange, onDelete]
|
||||
);
|
||||
|
||||
const formatFileSize = (bytes: bigint) => {
|
||||
const kb = Number(bytes) / 1024;
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)} KB`;
|
||||
}
|
||||
onImagesChange(images.filter((img) => img.id !== imageId));
|
||||
},
|
||||
[images, onImagesChange, onDelete]
|
||||
);
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: bigint) => {
|
||||
const kb = Number(bytes) / 1024;
|
||||
if (kb < 1024) {
|
||||
return `${kb.toFixed(1)} KB`;
|
||||
}
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
};
|
||||
const content = (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
const content = (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{/* Error message */}
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{errorMessage}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Read-only message */}
|
||||
{readOnly && images.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No images attached</p>
|
||||
)}
|
||||
|
||||
{/* Read-only message */}
|
||||
{readOnly && images.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No images attached</p>
|
||||
)}
|
||||
|
||||
{/* Drop zone - only show when not read-only */}
|
||||
{!readOnly && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Drag and drop images here, or click to select
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || isUploading}
|
||||
{/* Drop zone - only show when not read-only */}
|
||||
{!readOnly && (
|
||||
<div
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
Select Images
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group border rounded-lg p-2 bg-background"
|
||||
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Drag and drop images here, or click to select
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled || isUploading}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={imagesApi.getImageUrl(image.id)}
|
||||
alt={image.original_name}
|
||||
className="h-16 w-16 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{image.original_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(image.size_bytes)}
|
||||
</p>
|
||||
Select Images
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
void handleFiles(e.target.files);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image previews */}
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative group border rounded-lg p-2 bg-background"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={imagesApi.getImageUrl(image.id)}
|
||||
alt={image.original_name}
|
||||
className="h-16 w-16 object-cover rounded"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">
|
||||
{image.original_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(image.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && !readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && !readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uploading indicators */}
|
||||
{uploadingFiles.size > 0 && (
|
||||
<div className="space-y-1">
|
||||
{Array.from(uploadingFiles).map((tempId) => (
|
||||
<div
|
||||
key={tempId}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Uploading indicators */}
|
||||
{uploadingFiles.size > 0 && (
|
||||
<div className="space-y-1">
|
||||
{Array.from(uploadingFiles).map((tempId) => (
|
||||
<div
|
||||
key={tempId}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!collapsible) {
|
||||
return content;
|
||||
if (!collapsible) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>Images {images.length > 0 && `(${images.length})`}</span>
|
||||
</button>
|
||||
{isExpanded && content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3 w-3 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>Images {images.length > 0 && `(${images.length})`}</span>
|
||||
</button>
|
||||
{isExpanded && content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
ImageUploadSection.displayName = 'ImageUploadSection';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface FileSearchTextareaProps {
|
||||
maxRows?: number;
|
||||
onCommandEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onCommandShiftEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
onPasteFiles?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function FileSearchTextarea({
|
||||
@@ -34,6 +35,7 @@ export function FileSearchTextarea({
|
||||
onCommandEnter,
|
||||
onCommandShiftEnter,
|
||||
maxRows = 10,
|
||||
onPasteFiles,
|
||||
}: FileSearchTextareaProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
|
||||
@@ -78,6 +80,34 @@ export function FileSearchTextarea({
|
||||
return () => clearTimeout(debounceTimer);
|
||||
}, [searchQuery, projectId]);
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!onPasteFiles) return;
|
||||
|
||||
const clipboardData = e.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
const files: File[] = [];
|
||||
|
||||
if (clipboardData.files && clipboardData.files.length > 0) {
|
||||
files.push(...Array.from(clipboardData.files));
|
||||
} else if (clipboardData.items && clipboardData.items.length > 0) {
|
||||
Array.from(clipboardData.items).forEach((item) => {
|
||||
if (item.kind !== 'file') return;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
});
|
||||
}
|
||||
|
||||
const imageFiles = files.filter((file) =>
|
||||
file.type.toLowerCase().startsWith('image/')
|
||||
);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onPasteFiles(imageFiles);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle text changes and detect @ symbol
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
@@ -258,6 +288,7 @@ export function FileSearchTextarea({
|
||||
onKeyDown={handleKeyDown}
|
||||
onCommandEnter={onCommandEnter}
|
||||
onCommandShiftEnter={onCommandShiftEnter}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{showDropdown &&
|
||||
|
||||
Reference in New Issue
Block a user