mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add URL-based image import (single + bulk)
Add a fourth image ingestion path: importing images by URL. Backend: - POST /api/v1/fetch-urls endpoint with SSRF protection, image validation, preview generation, and p-queue concurrency - SSRF utility blocking private IPs, validating redirect hops, with comprehensive IPv4/IPv6 range coverage Frontend: - Always-visible URL input in the dropzone for quick single-image import - Bulk URL import modal with smart URL parsing (lists, markdown, HTML), per-URL progress tracking, retry on failure, and batch add - useUrlImport hook managing the full fetch lifecycle Tests: 48 new tests (23 SSRF unit, 10 URL parser unit, 15 integration)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { FileImage, ImageUp, Upload } from "lucide-react";
|
||||
import { type DragEvent, useCallback, useEffect, 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();
|
||||
@@ -224,6 +249,58 @@ export function Dropzone({
|
||||
PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMultipleFiles && (
|
||||
<div className="flex flex-col items-center gap-2 mt-1">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
||||
@@ -244,6 +321,16 @@ export function Dropzone({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showBulkModal && (
|
||||
<UrlImportModal
|
||||
onClose={() => setShowBulkModal(false)}
|
||||
onImport={(files) => {
|
||||
for (const file of files) onUrlImport?.(file);
|
||||
setShowBulkModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { AlertCircle, Check, Clock, Link, Loader2, RotateCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type UrlImportEntry, useUrlImport } from "@/hooks/use-url-import";
|
||||
import { extractUrls } from "@/lib/url-parser";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
interface UrlImportModalProps {
|
||||
onClose: () => void;
|
||||
onImport: (files: File[]) => void;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function StatusIcon({ status }: { status: UrlImportEntry["status"] }) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
case "fetching":
|
||||
return <Loader2 className="h-4 w-4 text-primary animate-spin" />;
|
||||
case "ready":
|
||||
return <Check className="h-4 w-4 text-emerald-500" />;
|
||||
case "failed":
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function filenameFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).pathname.split("/").pop() || url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
|
||||
export function UrlImportModal({ onClose, onImport }: UrlImportModalProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const { entries, importing, importUrls, addReadyFiles, retryUrl, cancel, reset, readyCount } =
|
||||
useUrlImport();
|
||||
|
||||
const hasResults = entries.length > 0;
|
||||
|
||||
const handleImport = useCallback(() => {
|
||||
const urls = extractUrls(text);
|
||||
if (urls.length === 0) return;
|
||||
importUrls(urls);
|
||||
}, [text, importUrls]);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
if (readyCount === 0) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const files = await addReadyFiles();
|
||||
if (files.length > 0) {
|
||||
onImport(files);
|
||||
onClose();
|
||||
}
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}, [readyCount, addReadyFiles, onImport, onClose]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancel();
|
||||
onClose();
|
||||
}, [cancel, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/60 cursor-default"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Modal card */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative z-10 w-full max-w-lg bg-background border border-border rounded-xl shadow-xl flex flex-col mx-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-border shrink-0">
|
||||
<Link className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-semibold text-foreground flex-1">Import from URLs</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-4 py-3 flex flex-col gap-3">
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={
|
||||
"https://example.com/photo1.jpg\nhttps://example.com/photo2.png\n- https://example.com/photo3.webp\n[My image](https://example.com/photo4.jpg)"
|
||||
}
|
||||
className="w-full min-h-[120px] max-h-[240px] resize-y rounded-lg border border-border bg-muted px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supports plain URLs, bulleted lists, numbered lists, and markdown links
|
||||
</p>
|
||||
|
||||
{/* Progress list */}
|
||||
{hasResults && (
|
||||
<div className="max-h-[200px] overflow-y-auto rounded-lg border border-border divide-y divide-border">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={entry.url} className="flex items-center gap-2.5 px-3 py-2 text-sm">
|
||||
<StatusIcon status={entry.status} />
|
||||
<span className="flex-1 truncate text-foreground">
|
||||
{entry.filename || filenameFromUrl(entry.url)}
|
||||
</span>
|
||||
{entry.status === "failed" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => retryUrl(i)}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground"
|
||||
title="Retry"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{entry.status === "ready" && entry.size != null && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasResults && !importing ? `${readyCount} of ${entries.length} ready` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasResults && !importing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={readyCount === 0 || adding}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{adding ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Add {readyCount} Image{readyCount !== 1 ? "s" : ""}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-border text-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={text.trim().length === 0 || importing}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
"Import"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface UrlImportEntry {
|
||||
url: string;
|
||||
status: "pending" | "fetching" | "ready" | "failed";
|
||||
filename?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
downloadUrl?: string;
|
||||
previewUrl?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FetchUrlResult {
|
||||
success: boolean;
|
||||
url: string;
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
downloadUrl?: string;
|
||||
previewUrl?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FetchUrlsResponse {
|
||||
results: FetchUrlResult[];
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────
|
||||
|
||||
export function useUrlImport() {
|
||||
const [entries, setEntries] = useState<UrlImportEntry[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// -- helpers --
|
||||
|
||||
const fetchUrls = useCallback(
|
||||
async (urls: string[], signal?: AbortSignal): Promise<FetchUrlsResponse> => {
|
||||
const headers = formatHeaders();
|
||||
headers.set("Content-Type", "application/json");
|
||||
const res = await fetch("/api/v1/fetch-urls", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ urls }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error((body as Record<string, string>).error || `Fetch failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resultToEntry = useCallback((result: FetchUrlResult): UrlImportEntry => {
|
||||
if (result.success) {
|
||||
return {
|
||||
url: result.url,
|
||||
status: "ready",
|
||||
filename: result.filename,
|
||||
size: result.size,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
downloadUrl: result.downloadUrl,
|
||||
previewUrl: result.previewUrl,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: result.url,
|
||||
status: "failed",
|
||||
error: result.error,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const downloadAsFile = useCallback(
|
||||
async (downloadUrl: string, filename: string, signal?: AbortSignal): Promise<File> => {
|
||||
const res = await fetch(downloadUrl, { headers: formatHeaders(), signal });
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return new File([blob], filename, { type: blob.type });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// -- public API --
|
||||
|
||||
const importUrls = useCallback(
|
||||
async (urls: string[]) => {
|
||||
if (urls.length === 0) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setEntries(urls.map((url) => ({ url, status: "fetching" })));
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const { results } = await fetchUrls(urls, controller.signal);
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
setEntries(results.map(resultToEntry));
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
|
||||
setEntries(
|
||||
urls.map((url) => ({
|
||||
url,
|
||||
status: "failed" as const,
|
||||
error: (err as Error).message,
|
||||
})),
|
||||
);
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchUrls, resultToEntry],
|
||||
);
|
||||
|
||||
const importSingleUrl = useCallback(
|
||||
async (url: string): Promise<File | null> => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const { results } = await fetchUrls([url], controller.signal);
|
||||
const result = results[0];
|
||||
if (!result?.success || !result.downloadUrl || !result.filename) return null;
|
||||
return await downloadAsFile(result.downloadUrl, result.filename, controller.signal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[fetchUrls, downloadAsFile],
|
||||
);
|
||||
|
||||
const addReadyFiles = useCallback(async (): Promise<File[]> => {
|
||||
const ready = entries.filter(
|
||||
(e): e is UrlImportEntry & { downloadUrl: string; filename: string } =>
|
||||
e.status === "ready" && !!e.downloadUrl && !!e.filename,
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
ready.map((e) => downloadAsFile(e.downloadUrl, e.filename)),
|
||||
);
|
||||
|
||||
return settled
|
||||
.filter((r): r is PromiseFulfilledResult<File> => r.status === "fulfilled")
|
||||
.map((r) => r.value);
|
||||
}, [entries, downloadAsFile]);
|
||||
|
||||
const retryUrl = useCallback(
|
||||
async (index: number) => {
|
||||
let url: string | undefined;
|
||||
setEntries((prev) => {
|
||||
url = prev[index]?.url;
|
||||
if (!url) return prev;
|
||||
return prev.map((e, i) =>
|
||||
i === index ? { ...e, status: "fetching" as const, error: undefined } : e,
|
||||
);
|
||||
});
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const { results } = await fetchUrls([url]);
|
||||
const result = results[0];
|
||||
if (!result) return;
|
||||
|
||||
setEntries((prev) => prev.map((e, i) => (i === index ? resultToEntry(result) : e)));
|
||||
} catch (err) {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, i) =>
|
||||
i === index ? { ...e, status: "failed" as const, error: (err as Error).message } : e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[fetchUrls, resultToEntry],
|
||||
);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setEntries([]);
|
||||
setImporting(false);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setEntries([]);
|
||||
setImporting(false);
|
||||
}, []);
|
||||
|
||||
// -- derived counts --
|
||||
|
||||
const readyCount = entries.filter((e) => e.status === "ready").length;
|
||||
const failedCount = entries.filter((e) => e.status === "failed").length;
|
||||
|
||||
return {
|
||||
entries,
|
||||
importing,
|
||||
importUrls,
|
||||
importSingleUrl,
|
||||
addReadyFiles,
|
||||
retryUrl,
|
||||
cancel,
|
||||
reset,
|
||||
readyCount,
|
||||
failedCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
function isValidHttpUrl(str: string): boolean {
|
||||
try {
|
||||
const url = new URL(str);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractUrls(input: string): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const rawLine of input.split("\n")) {
|
||||
let line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Strip numbered list prefixes: "1. ", "2) ", "3 "
|
||||
line = line.replace(/^\d+[.)]?\s+/, "");
|
||||
// Strip bullet prefixes: "- ", "* ", "+ "
|
||||
line = line.replace(/^[-*+]\s+/, "");
|
||||
|
||||
// Extract from markdown links: [text](url)
|
||||
const mdMatch = line.match(/\[.*?]\((https?:\/\/[^)]+)\)/);
|
||||
if (mdMatch) {
|
||||
urls.push(mdMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract from HTML img tags: <img src="url">
|
||||
const imgMatch = line.match(/<img[^>]+src=["'](https?:\/\/[^"']+)["']/i);
|
||||
if (imgMatch) {
|
||||
urls.push(imgMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isValidHttpUrl(line)) {
|
||||
urls.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
@@ -283,6 +283,13 @@ export function ToolPage() {
|
||||
[setFiles, reset],
|
||||
);
|
||||
|
||||
const handleUrlImport = useCallback(
|
||||
(file: File) => {
|
||||
addFiles([file]);
|
||||
},
|
||||
[addFiles],
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
undoProcessing();
|
||||
setEraserSliderInitPos(null);
|
||||
@@ -434,7 +441,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>}>
|
||||
@@ -637,7 +652,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)
|
||||
|
||||
Reference in New Issue
Block a user