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 { FileImage, Upload } from "lucide-react";
import { type DragEvent, useCallback, useState } from "react"; import { type DragEvent, useCallback, useState } from "react";
import { useUrlImport } from "@/hooks/use-url-import";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { UrlImportModal } from "./url-import-modal";
const IMAGE_EXTENSIONS = new Set([ const IMAGE_EXTENSIONS = new Set([
"jpg", "jpg",
@@ -79,6 +81,7 @@ export function isImageFile(file: File): boolean {
interface DropzoneProps { interface DropzoneProps {
onFiles?: (files: File[]) => void; onFiles?: (files: File[]) => void;
onUrlImport?: (file: File) => void;
accept?: string; accept?: string;
multiple?: boolean; multiple?: boolean;
/** Files that have already been dropped (for showing count & list). */ /** Files that have already been dropped (for showing count & list). */
@@ -96,6 +99,7 @@ function expandAccept(accept?: string): string | undefined {
export function Dropzone({ export function Dropzone({
onFiles, onFiles,
onUrlImport,
accept, accept,
multiple = true, multiple = true,
currentFiles = [], currentFiles = [],
@@ -103,6 +107,27 @@ export function Dropzone({
}: DropzoneProps) { }: DropzoneProps) {
const resolvedAccept = expandAccept(accept); const resolvedAccept = expandAccept(accept);
const [isDragging, setIsDragging] = useState(false); 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) => { const handleDrag = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -165,6 +190,58 @@ export function Dropzone({
</button> </button>
<p className="text-sm text-muted-foreground">Drop files here or click the upload button</p> <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 */} {/* Show file count badge and list when multiple files are dropped */}
{hasMultipleFiles && ( {hasMultipleFiles && (
<div className="flex flex-col items-center gap-2 mt-2"> <div className="flex flex-col items-center gap-2 mt-2">
@@ -186,6 +263,16 @@ export function Dropzone({
</div> </div>
)} )}
</div> </div>
{showBulkModal && (
<UrlImportModal
onClose={() => setShowBulkModal(false)}
onImport={(files) => {
for (const file of files) onUrlImport?.(file);
setShowBulkModal(false);
}}
/>
)}
</section> </section>
); );
} }
+25 -2
View File
@@ -281,6 +281,13 @@ export function ToolPage() {
[setFiles, reset], [setFiles, reset],
); );
const handleUrlImport = useCallback(
(file: File) => {
addFiles([file]);
},
[addFiles],
);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
undoProcessing(); undoProcessing();
setEraserSliderInitPos(null); setEraserSliderInitPos(null);
@@ -428,7 +435,15 @@ export function ToolPage() {
// Custom results panel (find-duplicates, etc.) // Custom results panel (find-duplicates, etc.)
if (displayMode === "custom-results" && registryEntry?.ResultsPanel) { if (displayMode === "custom-results" && registryEntry?.ResultsPanel) {
if (!hasFile) 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; const ResultsPanel = registryEntry.ResultsPanel;
return ( return (
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading...</div>}> <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) // Navigation arrows (shared between mobile/desktop)