feat: add useUrlImport hook for URL fetch lifecycle

This commit is contained in:
SnapOtter
2026-05-11 21:27:43 +08:00
parent d38101b8ea
commit 4a430abf2f
+215
View File
@@ -0,0 +1,215 @@
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): Promise<File> => {
const res = await fetch(downloadUrl, { headers: formatHeaders() });
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> => {
try {
const { results } = await fetchUrls([url]);
const result = results[0];
if (!result?.success || !result.downloadUrl || !result.filename) return null;
return await downloadAsFile(result.downloadUrl, result.filename);
} 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 files = await Promise.all(ready.map((e) => downloadAsFile(e.downloadUrl, e.filename)));
return files;
}, [entries, downloadAsFile]);
const retryUrl = useCallback(
async (index: number) => {
const entry = entries[index];
if (!entry) return;
setEntries((prev) =>
prev.map((e, i) =>
i === index ? { ...e, status: "fetching" as const, error: undefined } : e,
),
);
try {
const { results } = await fetchUrls([entry.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,
),
);
}
},
[entries, fetchUrls, resultToEntry],
);
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setEntries([]);
setImporting(false);
}, []);
const reset = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
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,
};
}