mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
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",
|
|
"jpeg",
|
|
"png",
|
|
"gif",
|
|
"webp",
|
|
"svg",
|
|
"bmp",
|
|
"avif",
|
|
"tiff",
|
|
"tif",
|
|
"ico",
|
|
"heic",
|
|
"heif",
|
|
"hif",
|
|
"jxl",
|
|
"apng",
|
|
"dng",
|
|
"cr2",
|
|
"cr3",
|
|
"nef",
|
|
"nrw",
|
|
"arw",
|
|
"orf",
|
|
"rw2",
|
|
"raf",
|
|
"pef",
|
|
"3fr",
|
|
"iiq",
|
|
"srw",
|
|
"x3f",
|
|
"rwl",
|
|
"gpr",
|
|
"fff",
|
|
"mrw",
|
|
"mef",
|
|
"kdc",
|
|
"dcr",
|
|
"erf",
|
|
"ptx",
|
|
"tga",
|
|
"psd",
|
|
"exr",
|
|
"hdr",
|
|
"svgz",
|
|
"jp2",
|
|
"j2k",
|
|
"j2c",
|
|
"jpc",
|
|
"jpf",
|
|
"jpx",
|
|
"qoi",
|
|
"eps",
|
|
"epsf",
|
|
"dds",
|
|
"cur",
|
|
"dpx",
|
|
"cin",
|
|
"fits",
|
|
"fit",
|
|
"fts",
|
|
"pbm",
|
|
"pgm",
|
|
"ppm",
|
|
"pnm",
|
|
"pam",
|
|
"pfm",
|
|
]);
|
|
|
|
export function isImageFile(file: File): boolean {
|
|
if (file.type.startsWith("image/")) return true;
|
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
|
return IMAGE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
interface DropzoneProps {
|
|
onFiles?: (files: File[]) => void;
|
|
onUrlImport?: (file: File) => void;
|
|
accept?: string;
|
|
multiple?: boolean;
|
|
/** Files that have already been dropped (for showing count & list). */
|
|
currentFiles?: File[];
|
|
/** Use a smaller layout that fits constrained containers like the pipeline preview. */
|
|
compact?: boolean;
|
|
}
|
|
|
|
// Browsers may not map certain formats (HEIC, JXL, RAW, etc.) to image/* in file pickers.
|
|
// Append explicit extensions so they are selectable.
|
|
function expandAccept(accept?: string): string | undefined {
|
|
if (!accept?.includes("image/*")) return accept;
|
|
return `${accept},.avif,.heic,.heif,.hif,.jxl,.ico,.dng,.cr2,.cr3,.nef,.nrw,.arw,.orf,.rw2,.raf,.pef,.3fr,.iiq,.srw,.x3f,.rwl,.gpr,.fff,.mrw,.mef,.kdc,.dcr,.erf,.ptx,.tga,.psd,.exr,.hdr,.svgz,.jp2,.j2k,.j2c,.jpc,.jpf,.jpx,.qoi,.eps,.epsf,.dds,.cur,.apng,.dpx,.cin,.fits,.fit,.fts,.ppm,.pgm,.pbm,.pnm,.pam,.pfm`;
|
|
}
|
|
|
|
export function Dropzone({
|
|
onFiles,
|
|
onUrlImport,
|
|
accept,
|
|
multiple = true,
|
|
currentFiles = [],
|
|
compact = false,
|
|
}: 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();
|
|
e.stopPropagation();
|
|
if (e.type === "dragenter" || e.type === "dragover") setIsDragging(true);
|
|
else if (e.type === "dragleave") setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(
|
|
(e: DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
const files = Array.from(e.dataTransfer.files).filter(isImageFile);
|
|
if (files.length > 0) onFiles?.(files);
|
|
},
|
|
[onFiles],
|
|
);
|
|
|
|
const handleClick = () => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.multiple = multiple;
|
|
if (resolvedAccept) input.accept = resolvedAccept;
|
|
input.onchange = (e) => {
|
|
const files = Array.from((e.target as HTMLInputElement).files || []);
|
|
if (files.length > 0) onFiles?.(files);
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
const hasMultipleFiles = currentFiles.length > 1;
|
|
|
|
return (
|
|
<section
|
|
aria-label="File drop zone"
|
|
onDragEnter={handleDrag}
|
|
onDragOver={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDrop={handleDrop}
|
|
className={cn(
|
|
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors mx-auto max-w-2xl w-full",
|
|
compact ? "min-h-0 h-full" : "min-h-[400px]",
|
|
isDragging
|
|
? "border-primary bg-primary/5"
|
|
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
|
|
)}
|
|
>
|
|
<div className={cn("flex flex-col items-center", compact ? "gap-2 p-4" : "gap-4 p-8")}>
|
|
<div className="text-3xl font-bold text-muted-foreground/30">
|
|
<span className="text-primary/30">SnapOtter</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleClick}
|
|
className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium"
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
Upload from computer
|
|
</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">
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
|
|
<FileImage className="h-3.5 w-3.5" />
|
|
{currentFiles.length} files selected
|
|
</span>
|
|
<div className="max-h-32 overflow-y-auto w-full max-w-xs">
|
|
{currentFiles.map((f) => (
|
|
<div
|
|
key={f.name}
|
|
className="flex items-center justify-between text-xs text-muted-foreground px-2 py-0.5"
|
|
>
|
|
<span className="truncate">{f.name}</span>
|
|
<span className="shrink-0 ml-2">{(f.size / 1024).toFixed(0)} KB</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showBulkModal && (
|
|
<UrlImportModal
|
|
onClose={() => setShowBulkModal(false)}
|
|
onImport={(files) => {
|
|
for (const file of files) onUrlImport?.(file);
|
|
setShowBulkModal(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|