From 4a430abf2f758f73503b9935329e4fe72fd7f4be Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Mon, 11 May 2026 21:27:43 +0800 Subject: [PATCH] feat: add useUrlImport hook for URL fetch lifecycle --- apps/web/src/hooks/use-url-import.ts | 215 +++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 apps/web/src/hooks/use-url-import.ts diff --git a/apps/web/src/hooks/use-url-import.ts b/apps/web/src/hooks/use-url-import.ts new file mode 100644 index 00000000..8271845e --- /dev/null +++ b/apps/web/src/hooks/use-url-import.ts @@ -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([]); + const [importing, setImporting] = useState(false); + const abortRef = useRef(null); + + // -- helpers -- + + const fetchUrls = useCallback( + async (urls: string[], signal?: AbortSignal): Promise => { + 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).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 => { + 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 => { + 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 => { + 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, + }; +}