"use client"; import { CloudAlertIcon, CloudCheckIcon, CloudUploadIcon, FileWarningIcon, RotateCwIcon, WifiOffIcon, XIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export type UploadIssue = | { kind: "too-large"; maxBytes: number } | { kind: "type"; allowed: string[]; hint?: string } | { kind: "connection" }; export interface UploadFailedFile { id: string; name: string; size: number; progress: number; status: "uploading" | "done" | "failed"; issue?: UploadIssue; } export interface UploadFailedProps { files?: UploadFailedFile[]; destination?: string; onRetry?: (id: string) => void; onRetryAll?: () => void; onRemove?: (id: string) => void; onRemoveFailed?: () => void; onReplace?: (id: string, file: File) => void; className?: string; } const MB = 1024 ** 2; const defaultFiles: UploadFailedFile[] = [ { id: "brief", name: "survey-brief.pdf", size: 2.4 * MB, progress: 1, status: "done" }, { id: "walkthrough", name: "north-ridge-walkthrough.mov", size: 18 * MB, progress: 0, status: "failed", issue: { kind: "too-large", maxBytes: 10 * MB } }, { id: "photo", name: "trailhead-014.heic", size: 3.1 * MB, progress: 0, status: "failed", issue: { kind: "type", allowed: ["JPG", "PNG", "PDF", "MOV", "CSV"], hint: "Export HEIC photos as JPG first." } }, { id: "responses", name: "visitor-responses.csv", size: 6.8 * MB, progress: 0.62, status: "failed", issue: { kind: "connection" } }, { id: "cover", name: "cover-north-ridge.jpg", size: 1.2 * MB, progress: 1, status: "done" }, ]; const tones: { match: RegExp; className: string }[] = [ { match: /^pdf$/, className: "text-red-600 dark:text-red-400" }, { match: /^(jpe?g|png|gif|webp|heic|avif)$/, className: "text-sky-700 dark:text-sky-400" }, { match: /^(mov|mp4|webm)$/, className: "text-violet-600 dark:text-violet-400" }, { match: /^(csv|xlsx?)$/, className: "text-emerald-700 dark:text-emerald-400" }, ]; const spring = { type: "spring", stiffness: 420, damping: 36 } as const; const fill = { type: "spring", stiffness: 140, damping: 24 } as const; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function extensionOf(name: string) { return name.split(".").pop()?.toLowerCase() ?? ""; } function formatBytes(bytes: number) { if (bytes < MB) return `${Math.max(1, Math.round(bytes / 1024))} KB`; const mb = bytes / MB; return `${mb >= 10 ? Math.round(mb) : mb.toFixed(1)} MB`; } function listOf(items: string[]) { if (items.length < 2) return items.join(""); return `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`; } export function UploadFailed({ files = defaultFiles, destination = "Q3 site survey", onRetry, onRetryAll, onRemove, onRemoveFailed, onReplace, className, }: UploadFailedProps) { const reduce = useSafeReducedMotion(); const failed = files.filter((f) => f.status === "failed"); const uploading = files.filter((f) => f.status === "uploading").length; const done = files.filter((f) => f.status === "done").length; const retryable = failed.filter((f) => f.issue?.kind === "connection").length; const state = failed.length > 0 ? "failed" : uploading > 0 ? "uploading" : "done"; const title = state === "failed" ? `${failed.length} of ${files.length} didn't upload` : state === "uploading" ? `Uploading ${uploading} of ${files.length}` : files.length === 0 ? "Nothing to upload" : `${done} of ${files.length} uploaded`; const Icon = state === "failed" ? CloudAlertIcon : state === "uploading" ? CloudUploadIcon : CloudCheckIcon; return (

{title}

{state === "failed" ? `${done} saved to ` : state === "uploading" ? "Saving to " : "All in "} {destination}

{failed.length > 0 && ( )}
{files.map((f) => ( ))}
); } function UploadRow({ file, reduce, onRetry, onRemove, onReplace, }: { file: UploadFailedFile; reduce: boolean; onRetry?: () => void; onRemove?: () => void; onReplace?: (file: File) => void; }) { const ext = extensionOf(file.name); const tone = tones.find((t) => t.match.test(ext))?.className ?? "text-muted-foreground"; const issue = file.status === "failed" ? file.issue : undefined; const percent = Math.round(file.progress * 100); return (
{ext.slice(0, 4)}
{file.name} {file.status === "uploading" ? `${percent}%` : formatBytes(file.size)}
{issue && }
{file.status === "done" ? ( ) : file.status === "failed" && onRemove ? ( ) : null}
); } function Collapse({ show, reduce, children }: { show: boolean; reduce: boolean; children: ReactNode }) { return ( {show && (
{children}
)}
); } function IssueLine({ issue, fileName, percent, size, onRetry, onReplace, }: { issue: UploadIssue; fileName: string; percent: number; size: number; onRetry?: () => void; onReplace?: (file: File) => void; }) { const input = useRef(null); const reason = issue.kind === "too-large" ? `Too large (${formatBytes(size)}, max ${formatBytes(issue.maxBytes)})` : issue.kind === "type" ? `Type not allowed (.${extensionOf(fileName)})` : `Connection dropped at ${percent}%`; const ReasonIcon = issue.kind === "connection" ? WifiOffIcon : FileWarningIcon; const accept = issue.kind === "type" ? issue.allowed.map((t) => `.${t.toLowerCase()}`).join(",") : undefined; return (

{reason}

{issue.kind === "type" && (

Use {listOf(issue.allowed)}.{issue.hint ? ` ${issue.hint}` : ""}

)} {issue.kind === "connection" &&

The first {percent}% is saved. Retry picks up from there.

}
{issue.kind === "connection" ? ( ) : ( <> { const picked = e.target.files?.[0]; if (picked) onReplace?.(picked); e.target.value = ""; }} ref={input} tabIndex={-1} type="file" /> )}
); } function DrawnCheck({ reduce }: { reduce: boolean }) { return ( ); }