"use client"; import { useEffect, useState, useCallback, useRef, useTransition } from "react"; import { Image as ImageIcon, Loader2, Check, AlertCircle, ImageOff, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { setPreviewFromExtract } from "../actions"; interface ArchiveImage { id: string; path: string; fileName: string; extension: string | null; size: string; } interface ThumbnailState { status: "idle" | "loading" | "loaded" | "failed"; requestId?: string; imageUrl?: string; error?: string; } interface ArchivePreviewPickerProps { packageId: string; packageName: string; open: boolean; onOpenChange: (open: boolean) => void; onPreviewSet?: () => void; } function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } export function ArchivePreviewPicker({ packageId, packageName, open, onOpenChange, onPreviewSet, }: ArchivePreviewPickerProps) { const [images, setImages] = useState([]); const [loading, setLoading] = useState(false); const [thumbnails, setThumbnails] = useState>(new Map()); const [selectedPath, setSelectedPath] = useState(null); const [isPending, startTransition] = useTransition(); const pollTimers = useRef>>(new Map()); // Track which paths have already been requested to avoid re-requesting const requestedPaths = useRef>(new Set()); // Cleanup poll timers on unmount useEffect(() => { return () => { for (const timer of pollTimers.current.values()) { clearInterval(timer); } }; }, []); // Fetch image list when opened useEffect(() => { if (!open) return; setImages([]); setThumbnails(new Map()); setSelectedPath(null); requestedPaths.current.clear(); // Clear any leftover poll timers for (const timer of pollTimers.current.values()) { clearInterval(timer); } pollTimers.current.clear(); const fetchImages = async () => { setLoading(true); try { const res = await fetch(`/api/zips/${packageId}/images`); if (!res.ok) throw new Error("Failed to fetch images"); const data = await res.json(); setImages(data.images); } catch { toast.error("Failed to load archive images"); } finally { setLoading(false); } }; fetchImages(); }, [open, packageId]); // Poll callback for a specific request const startPolling = useCallback( (filePath: string, requestId: string) => { // Clear any existing poll for this path const existing = pollTimers.current.get(filePath); if (existing) clearInterval(existing); const pollId = setInterval(async () => { try { const pollRes = await fetch( `/api/zips/${packageId}/extract/${requestId}` ); if (!pollRes.ok) return; const pollData = await pollRes.json(); if (pollData.status === "COMPLETED") { clearInterval(pollId); pollTimers.current.delete(filePath); setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "loaded", requestId, imageUrl: `/api/zips/${packageId}/extract/${requestId}?image=true`, }); return next; }); } else if (pollData.status === "FAILED") { clearInterval(pollId); pollTimers.current.delete(filePath); setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "failed", error: pollData.error || "Extraction failed", }); return next; }); } } catch { // Silently retry on network error } }, 2000); pollTimers.current.set(filePath, pollId); }, [packageId] ); // Request extraction for a specific image const requestThumbnail = useCallback( async (filePath: string) => { // Don't re-request if already in progress if (requestedPaths.current.has(filePath)) return; requestedPaths.current.add(filePath); setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "loading" }); return next; }); try { const res = await fetch(`/api/zips/${packageId}/extract`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filePath }), }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || "Extract failed"); } const data = await res.json(); if (data.status === "COMPLETED") { setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "loaded", requestId: data.requestId, imageUrl: `/api/zips/${packageId}/extract/${data.requestId}?image=true`, }); return next; }); return; } // Pending or in-progress: start polling setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "loading", requestId: data.requestId }); return next; }); startPolling(filePath, data.requestId); } catch (err) { requestedPaths.current.delete(filePath); setThumbnails((prev) => { const next = new Map(prev); next.set(filePath, { status: "failed", error: err instanceof Error ? err.message : "Failed to extract", }); return next; }); } }, [packageId, startPolling] ); // Auto-request thumbnails for the first batch of images useEffect(() => { if (!open || images.length === 0) return; // Request the first 12 images automatically const toRequest = images.slice(0, 12); for (const img of toRequest) { requestThumbnail(img.path); } // Only trigger when images list changes, not on every requestThumbnail change // eslint-disable-next-line react-hooks/exhaustive-deps }, [images, open]); // Handle selection confirmation const handleConfirm = () => { if (!selectedPath) return; const thumbState = thumbnails.get(selectedPath); if (!thumbState?.requestId) return; startTransition(async () => { const result = await setPreviewFromExtract(packageId, thumbState.requestId!); if (result.success) { toast.success("Preview updated from archive image"); onOpenChange(false); onPreviewSet?.(); } else { toast.error(result.error); } }); }; return ( Select Preview Image Choose an image from the archive to use as the preview for{" "} {packageName}
{loading ? (
Loading image list...
) : images.length === 0 ? (
No images found in this archive
) : (
{images.map((img) => { const thumbState = thumbnails.get(img.path); const isSelected = selectedPath === img.path; const isLoaded = thumbState?.status === "loaded"; const isLoading = thumbState?.status === "loading"; const isFailed = thumbState?.status === "failed"; return ( ); })}
)}
{/* Footer */} {images.length > 0 && (
{images.length} image{images.length !== 1 ? "s" : ""} found
)}
); }