feat: add inline URL input to dropzone

This commit is contained in:
SnapOtter
2026-05-11 21:32:55 +08:00
parent d8941a2bc8
commit b7af25862a
2 changed files with 112 additions and 2 deletions
@@ -1,6 +1,8 @@
import { FileImage, Upload } from "lucide-react";
import { type DragEvent, useCallback, useState } from "react";
import { useUrlImport } from "@/hooks/use-url-import";
import { cn } from "@/lib/utils";
import { UrlImportModal } from "./url-import-modal";
const IMAGE_EXTENSIONS = new Set([
"jpg",
@@ -79,6 +81,7 @@ export function isImageFile(file: File): boolean {
interface DropzoneProps {
onFiles?: (files: File[]) => void;
onUrlImport?: (file: File) => void;
accept?: string;
multiple?: boolean;
/** Files that have already been dropped (for showing count & list). */
@@ -96,6 +99,7 @@ function expandAccept(accept?: string): string | undefined {
export function Dropzone({
onFiles,
onUrlImport,
accept,
multiple = true,
currentFiles = [],
@@ -103,6 +107,27 @@ export function Dropzone({
}: DropzoneProps) {
const resolvedAccept = expandAccept(accept);
const [isDragging, setIsDragging] = useState(false);
const [urlInput, setUrlInput] = useState("");
const [urlLoading, setUrlLoading] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null);
const [showBulkModal, setShowBulkModal] = useState(false);
const { importSingleUrl } = useUrlImport();
const handleUrlSubmit = useCallback(async () => {
const url = urlInput.trim();
if (!url) return;
setUrlLoading(true);
setUrlError(null);
const file = await importSingleUrl(url);
if (file) {
setUrlInput("");
onUrlImport?.(file);
} else {
setUrlError("Could not fetch image from URL");
}
setUrlLoading(false);
}, [urlInput, importSingleUrl, onUrlImport]);
const handleDrag = useCallback((e: DragEvent) => {
e.preventDefault();
@@ -165,6 +190,58 @@ export function Dropzone({
</button>
<p className="text-sm text-muted-foreground">Drop files here or click the upload button</p>
{!compact && onUrlImport && (
<>
<div className="flex items-center gap-2 w-full max-w-xs">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex gap-2 w-full max-w-sm">
<input
type="url"
value={urlInput}
onChange={(e) => {
setUrlInput(e.target.value);
setUrlError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleUrlSubmit();
}
}}
onClick={(e) => e.stopPropagation()}
placeholder="Paste image URL..."
className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
disabled={urlLoading}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUrlSubmit();
}}
disabled={urlLoading || !urlInput.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{urlLoading ? "..." : "Add"}
</button>
</div>
{urlError && <p className="text-xs text-destructive">{urlError}</p>}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowBulkModal(true);
}}
className="text-xs text-primary hover:text-primary/80"
>
Import multiple URLs...
</button>
</>
)}
{/* Show file count badge and list when multiple files are dropped */}
{hasMultipleFiles && (
<div className="flex flex-col items-center gap-2 mt-2">
@@ -186,6 +263,16 @@ export function Dropzone({
</div>
)}
</div>
{showBulkModal && (
<UrlImportModal
onClose={() => setShowBulkModal(false)}
onImport={(files) => {
for (const file of files) onUrlImport?.(file);
setShowBulkModal(false);
}}
/>
)}
</section>
);
}
+25 -2
View File
@@ -281,6 +281,13 @@ export function ToolPage() {
[setFiles, reset],
);
const handleUrlImport = useCallback(
(file: File) => {
addFiles([file]);
},
[addFiles],
);
const handleUndo = useCallback(() => {
undoProcessing();
setEraserSliderInitPos(null);
@@ -428,7 +435,15 @@ export function ToolPage() {
// Custom results panel (find-duplicates, etc.)
if (displayMode === "custom-results" && registryEntry?.ResultsPanel) {
if (!hasFile)
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
return (
<Dropzone
onFiles={handleFiles}
onUrlImport={handleUrlImport}
accept="image/*"
multiple
currentFiles={files}
/>
);
const ResultsPanel = registryEntry.ResultsPanel;
return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}>
@@ -626,7 +641,15 @@ export function ToolPage() {
);
}
return <Dropzone onFiles={handleFiles} accept="image/*" multiple currentFiles={files} />;
return (
<Dropzone
onFiles={handleFiles}
onUrlImport={handleUrlImport}
accept="image/*"
multiple
currentFiles={files}
/>
);
}
// Navigation arrows (shared between mobile/desktop)