Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 32 additions & 16 deletions src/components/EndpointForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,34 +181,50 @@ export function EndpointForm({ endpoint, onSubmit, onPriceCheck, isSubmitting }:
}, [models]);

const handleFileChange = useCallback(async (name: string, file: File | File[] | null) => {
setImagePreviews((prev) => {
const oldPreviews = prev[name];
if (oldPreviews) {
oldPreviews.forEach((p) => URL.revokeObjectURL(p.url));
}
const newPreviews = { ...prev };
delete newPreviews[name];
return newPreviews;
});

if (file) {
setFiles((prev) => ({ ...prev, [name]: file }));
const incomingFiles = Array.isArray(file) ? file : [file];
const isMulti = Array.isArray(file);

// In multi mode, append to existing files
const mergedFiles = isMulti
? [...(Array.isArray(files[name]) ? files[name] as File[] : files[name] ? [files[name] as File] : []), ...incomingFiles]
: incomingFiles;

const fileArray = Array.isArray(file) ? file : [file];
const imageFiles = fileArray.filter((f) => f.type.startsWith('image/'));
setFiles((prev) => ({ ...prev, [name]: isMulti ? mergedFiles : mergedFiles[0] }));

const imageFiles = incomingFiles.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
const previews = await Promise.all(imageFiles.map(generateImagePreview));
setImagePreviews((prev) => ({ ...prev, [name]: previews }));
const newPreviews = await Promise.all(imageFiles.map(generateImagePreview));
setImagePreviews((prev) => ({
...prev,
[name]: isMulti ? [...(prev[name] || []), ...newPreviews] : newPreviews,
}));
} else if (!isMulti) {
// Single mode non-image: clear old previews
setImagePreviews((prev) => {
const old = prev[name];
if (old) old.forEach((p) => URL.revokeObjectURL(p.url));
const next = { ...prev };
delete next[name];
return next;
});
}
} else {
// Clear all
setImagePreviews((prev) => {
const old = prev[name];
if (old) old.forEach((p) => URL.revokeObjectURL(p.url));
const next = { ...prev };
delete next[name];
return next;
});
setFiles((prev) => {
const newFiles = { ...prev };
delete newFiles[name];
return newFiles;
});
}
}, []);
}, [files]);

const toggleNullable = useCallback((name: string, disabled: boolean) => {
setNullableDisabled((prev) => ({ ...prev, [name]: disabled }));
Expand Down
132 changes: 95 additions & 37 deletions src/components/form/FileUploadField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState } from 'react';
import { useState, useRef, DragEvent } from 'react';
import { Upload, X, FolderOpen } from 'lucide-react';
import { formatFileSize } from '@/lib/format-utils';
import { OutputPicker } from './OutputPicker';
Expand Down Expand Up @@ -41,8 +41,49 @@ export function FileUploadField({
onModeChange,
}: FileUploadFieldProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragCounter = useRef(0);
const isImageAccept = accept?.includes('image/') ?? false;

const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true);
}
};

const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};

const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};

const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounter.current = 0;

const droppedFiles = e.dataTransfer.files;
if (!droppedFiles || droppedFiles.length === 0) return;

if (isMultiMode) {
onFileChange(name, Array.from(droppedFiles));
} else {
onFileChange(name, droppedFiles[0]);
}
};

const fileArray = files ? (Array.isArray(files) ? files : [files]) : [];
const fileCount = fileArray.length;
const fileLabel =
Expand Down Expand Up @@ -118,43 +159,60 @@ export function FileUploadField({
</div>
)}

<div className="flex items-center gap-1">
<label className="flex items-center gap-2 px-2 py-1.5 bg-[var(--surface-2)] border border-[var(--border)] rounded cursor-pointer hover:border-[var(--border-strong)] transition-colors flex-1 min-w-0">
<Upload className="w-3 h-3 text-[var(--muted)] flex-shrink-0" />
<span className="text-xs text-[var(--text-secondary)] truncate flex-1">{fileLabel}</span>
{isMultiMode && fileCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">
{fileCount}
</span>
)}
<input
type="file"
accept={accept}
multiple={isMultiMode}
onChange={(e) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) {
onFileChange(name, null);
} else if (isMultiMode) {
onFileChange(name, Array.from(selectedFiles));
} else {
onFileChange(name, selectedFiles[0]);
}
}}
className="hidden"
/>
</label>

{isImageAccept && (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex items-center justify-center w-8 h-8 bg-[var(--surface-2)] border border-[var(--border)] rounded hover:border-[var(--border-strong)] hover:bg-[var(--hover)] transition-colors flex-shrink-0"
title="Pick from output"
>
<FolderOpen className="w-3.5 h-3.5 text-[var(--muted)]" />
</button>
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={`relative rounded border-2 border-dashed transition-colors ${
isDragging
? 'border-blue-500 bg-blue-500/10'
: 'border-transparent'
}`}
>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center z-10 rounded pointer-events-none">
<span className="text-xs font-medium text-blue-400">Drop {isMultiMode ? 'file(s)' : 'file'} here</span>
</div>
)}
<div className={`flex items-center gap-1 ${isDragging ? 'opacity-30' : ''}`}>
<label className="flex items-center gap-2 px-2 py-1.5 bg-[var(--surface-2)] border border-[var(--border)] rounded cursor-pointer hover:border-[var(--border-strong)] transition-colors flex-1 min-w-0">
<Upload className="w-3 h-3 text-[var(--muted)] flex-shrink-0" />
<span className="text-xs text-[var(--text-secondary)] truncate flex-1">{fileLabel}</span>
{isMultiMode && fileCount > 0 && (
<span className="text-[10px] px-1.5 py-0.5 bg-blue-500/20 text-blue-400 rounded">
{fileCount}
</span>
)}
<input
type="file"
accept={accept}
multiple={isMultiMode}
onChange={(e) => {
const selectedFiles = e.target.files;
if (!selectedFiles || selectedFiles.length === 0) {
onFileChange(name, null);
} else if (isMultiMode) {
onFileChange(name, Array.from(selectedFiles));
} else {
onFileChange(name, selectedFiles[0]);
}
}}
className="hidden"
/>
</label>

{isImageAccept && (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="flex items-center justify-center w-8 h-8 bg-[var(--surface-2)] border border-[var(--border)] rounded hover:border-[var(--border-strong)] hover:bg-[var(--hover)] transition-colors flex-shrink-0"
title="Pick from output"
>
<FolderOpen className="w-3.5 h-3.5 text-[var(--muted)]" />
</button>
)}
</div>
</div>

{isImageAccept && (
Expand Down