mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add inline URL input to dropzone
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user