Files
SnapOtter/apps/web/src/components/common/dropzone.tsx
T

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>
);
}