mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
|
|
import { type DragEvent, useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "@/contexts/i18n-context";
|
|
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;
|
|
/** Override the default isImageFile check for drag/drop and paste. */
|
|
fileFilter?: (file: File) => boolean;
|
|
/** Override the format hint text (e.g. "SVG files only"). */
|
|
acceptDescription?: string;
|
|
}
|
|
|
|
// 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,
|
|
fileFilter,
|
|
acceptDescription,
|
|
}: DropzoneProps) {
|
|
const { t } = useTranslation();
|
|
const checkFile = fileFilter ?? isImageFile;
|
|
const resolvedAccept = expandAccept(accept);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [urlInput, setUrlInput] = useState("");
|
|
const [urlLoading, setUrlLoading] = useState(false);
|
|
const [urlError, setUrlError] = useState<string | null>(null);
|
|
const [showBulkModal, setShowBulkModal] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (error) {
|
|
const timer = setTimeout(() => setError(null), 5000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [error]);
|
|
|
|
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) {
|
|
if (!checkFile(file)) {
|
|
setUrlError(acceptDescription ?? t.dropzone.unsupportedFileType);
|
|
} else {
|
|
setUrlInput("");
|
|
onUrlImport?.(file);
|
|
}
|
|
} else {
|
|
setUrlError(t.dropzone.urlFetchFailed);
|
|
}
|
|
setUrlLoading(false);
|
|
}, [urlInput, importSingleUrl, onUrlImport, checkFile, acceptDescription]);
|
|
|
|
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);
|
|
setError(null);
|
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
|
const validFiles = droppedFiles.filter(checkFile);
|
|
if (validFiles.length > 0) {
|
|
onFiles?.(validFiles);
|
|
} else if (droppedFiles.length > 0) {
|
|
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
|
|
}
|
|
},
|
|
[onFiles, checkFile, acceptDescription, accept],
|
|
);
|
|
|
|
const handleClick = () => {
|
|
setError(null);
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.multiple = multiple;
|
|
if (resolvedAccept) input.accept = resolvedAccept;
|
|
input.onchange = (e) => {
|
|
const picked = Array.from((e.target as HTMLInputElement).files || []);
|
|
const validFiles = picked.filter(checkFile);
|
|
if (validFiles.length > 0) {
|
|
onFiles?.(validFiles);
|
|
} else if (picked.length > 0) {
|
|
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
|
|
}
|
|
};
|
|
input.click();
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
const clip = e.clipboardData;
|
|
if (!clip) return;
|
|
|
|
const files: File[] = [];
|
|
|
|
if (clip.files.length > 0) {
|
|
for (const file of clip.files) {
|
|
if (checkFile(file)) files.push(file);
|
|
}
|
|
} else if (clip.items) {
|
|
for (const item of clip.items) {
|
|
if (item.kind === "file") {
|
|
const file = item.getAsFile();
|
|
if (file && checkFile(file)) files.push(file);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (files.length > 0) {
|
|
e.preventDefault();
|
|
onFiles?.(files);
|
|
}
|
|
};
|
|
document.addEventListener("paste", handlePaste);
|
|
return () => document.removeEventListener("paste", handlePaste);
|
|
}, [onFiles, checkFile]);
|
|
|
|
const hasMultipleFiles = currentFiles.length > 1;
|
|
|
|
return (
|
|
<section
|
|
aria-label={t.a11y.fileDropZone}
|
|
onDragEnter={handleDrag}
|
|
onDragOver={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDrop={handleDrop}
|
|
onClick={handleClick}
|
|
className={cn(
|
|
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
|
|
compact ? "min-h-0 h-full" : "min-h-[400px]",
|
|
isDragging
|
|
? "border-primary bg-primary/10 scale-[1.01]"
|
|
: "border-border/60 bg-muted/20 hover:border-primary/40 hover:bg-muted/40",
|
|
)}
|
|
>
|
|
<div className={cn("flex flex-col items-center", compact ? "gap-2 p-4" : "gap-5 p-8")}>
|
|
<div
|
|
className={cn(
|
|
"rounded-2xl bg-primary/8 p-4 transition-colors duration-200",
|
|
isDragging ? "bg-primary/15" : "group-hover:bg-primary/12",
|
|
)}
|
|
>
|
|
<FileUp
|
|
className={cn(
|
|
"transition-all duration-200",
|
|
compact ? "h-8 w-8" : "h-10 w-10",
|
|
isDragging ? "text-primary" : "text-primary/50 group-hover:text-primary/70",
|
|
)}
|
|
strokeWidth={1.5}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-1.5">
|
|
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
|
|
{t.dropzone.dropPrompt}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">{t.dropzone.browseOrPaste}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleClick();
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 rounded-lg bg-primary text-primary-foreground transition-all duration-200 text-sm font-medium shadow-sm",
|
|
compact ? "px-5 py-2" : "px-8 py-3",
|
|
"hover:bg-primary/90 hover:shadow-md active:scale-[0.98]",
|
|
)}
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
{t.common.upload}
|
|
</button>
|
|
<p className="text-xs text-muted-foreground">
|
|
{acceptDescription ?? t.dropzone.defaultFormats}
|
|
</p>
|
|
|
|
{error && (
|
|
<p className="mt-3 text-sm text-destructive flex items-center gap-1.5">
|
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
|
{error}
|
|
</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">{t.dropzone.orSeparator}</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={t.dropzone.urlPlaceholder}
|
|
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 ? t.dropzone.urlLoadingIndicator : t.dropzone.addUrlButton}
|
|
</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"
|
|
>
|
|
{t.dropzone.importMultipleUrls}
|
|
</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">
|
|
<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 ms-2">{(f.size / 1024).toFixed(0)} KB</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showBulkModal && (
|
|
<UrlImportModal
|
|
onClose={() => setShowBulkModal(false)}
|
|
onImport={(files) => {
|
|
for (const file of files) {
|
|
if (checkFile(file)) onUrlImport?.(file);
|
|
}
|
|
setShowBulkModal(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|