"use client"; import { Check, CloudDownload, Loader2 } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { adminFetch } from "@/lib/admin-fetch"; interface SyncEvent { type: string; classname?: string; status?: string; succeeded?: number; failed?: number; } export function SyncAllClient() { const [syncing, setSyncing] = useState(false); const [progress, setProgress] = useState<{ done: number; total: number; } | null>(null); const [log, setLog] = useState([]); const [result, setResult] = useState<{ succeeded: number; failed: number; sources: number; } | null>(null); const abortRef = useRef(null); const startSync = useCallback(async () => { setSyncing(true); setProgress(null); setLog([]); setResult(null); const abort = new AbortController(); abortRef.current = abort; try { const res = await adminFetch("/api/admin/import/clone/sync-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), signal: abort.signal, }); if (!res.body) { toast.error("No response stream"); setSyncing(false); return; } if (!res.headers.get("content-type")?.includes("text/event-stream")) { let errMsg = "Sync failed"; try { const json = await res.json(); errMsg = json.error || errMsg; } catch { // ignore } toast.error(errMsg); setSyncing(false); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; let succeeded = 0; let failed = 0; let totalItems = 0; while (true) { const { value, done: streamDone } = await reader.read(); if (streamDone) break; buf += decoder.decode(value, { stream: true }); const parts = buf.split("\n\n"); buf = parts.pop() ?? ""; for (const part of parts) { if (!part.startsWith("data: ")) continue; try { const evt = JSON.parse(part.slice(6)) as SyncEvent; if ( evt.type === "item_progress" && (evt.status === "done" || evt.status === "failed") ) { totalItems++; if (evt.status === "done") succeeded++; else failed++; setProgress({ done: succeeded + failed, total: totalItems }); setLog((prev) => [ ...prev, `${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`, ]); } if (evt.type === "batch_complete") { succeeded = evt.succeeded ?? succeeded; failed = evt.failed ?? failed; } } catch { // skip parse errors } } } // Check for skipped state (all up to date) try { const parsed = JSON.parse( buf.includes("data:") ? (buf.split("data:").pop() ?? "{}") : "{}", ); if (parsed.skipped) { toast.info(parsed.message || "All sources are up to date"); setResult({ succeeded: 0, failed: 0, sources: 0 }); setSyncing(false); return; } } catch { // not a skipped response } setResult({ succeeded, failed, sources: 1 }); if (succeeded > 0) toast.success(`${succeeded} items synced`); if (failed > 0) toast.error(`${failed} items failed`); } catch (err) { if ((err as Error).name === "AbortError") { toast.info("Sync cancelled"); } else { toast.error("Sync request failed"); } } finally { setSyncing(false); abortRef.current = null; } }, []); return (
{!result && (
{syncing && ( )}
)} {progress && (
Processed {progress.done} items {progress.total > 0 ? ` out of ${progress.total}` : ""}
)} {log.length > 0 && (
{log.map((line) => (
{line}
))}
)} {result && result.sources > 0 && (
Sync Complete

Succeeded:{" "} {result.succeeded}

Failed:{" "} {result.failed}

)} {!syncing && !result && (

Click the button above to scan all clone sources for missing furniture and import it automatically.

)}
); }