mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
229 lines
8.3 KiB
TypeScript
229 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|