UNPKG

bigblocks

Version:

Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React

342 lines (341 loc) 19.3 kB
"use client"; import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; import { CheckCircledIcon, ClockIcon, CrossCircledIcon, ExternalLinkIcon, FileIcon, InfoCircledIcon, TrashIcon, UpdateIcon, } from "@radix-ui/react-icons"; import { useCallback, useState } from "react"; import { cn } from "../../lib/utils.js"; import { DropZone } from "../ui-components/DropZone.js"; import { ErrorDisplay } from "../ui-components/ErrorDisplay.js"; import { LoadingButton } from "../ui-components/LoadingButton.js"; import { Badge } from "../ui/badge.js"; import { Button } from "../ui/button.js"; import { Card, CardContent, CardHeader, CardTitle } from "../ui/card.js"; import { Progress } from "../ui/progress.js"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../ui/table.js"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../ui/tooltip.js"; // File sync status enum export var FileSyncStatus; (function (FileSyncStatus) { FileSyncStatus["Pending"] = "pending"; FileSyncStatus["Checking"] = "checking"; FileSyncStatus["OnChain"] = "on-chain"; FileSyncStatus["NeedsSync"] = "needs-sync"; FileSyncStatus["Syncing"] = "syncing"; FileSyncStatus["Synced"] = "synced"; FileSyncStatus["Failed"] = "failed"; })(FileSyncStatus || (FileSyncStatus = {})); // Calculate file hash (using Web Crypto API) async function calculateFileHash(file) { const arrayBuffer = await file.arrayBuffer(); const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } // Check if file exists on blockchain via ordfs API async function checkFileOnChain(hash) { try { // Check ordfs.network API for file existence const response = await fetch(`https://ordfs.network/api/files/${hash}`, { method: "GET", headers: { Accept: "application/json", }, }); if (response.ok) { const data = await response.json(); return { exists: true, txid: data.txid || data.outpoint?.split("_")[0], }; } if (response.status === 404) { return { exists: false }; } throw new Error(`API error: ${response.status}`); } catch (error) { console.warn("Error checking file on chain:", error); // Fallback: assume file doesn't exist if API check fails return { exists: false }; } } // Estimate transaction fee based on file size function estimateTransactionFee(fileSizeBytes, feePerKB = 500) { // Basic estimation: base tx size + file data const baseTxSize = 250; // bytes const totalSize = baseTxSize + fileSizeBytes; const sizeInKB = totalSize / 1024; return Math.ceil(sizeInKB * feePerKB); } // Format file size for display function formatFileSize(bytes) { 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 `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`; } // Format satoshis for display function formatSatoshis(satoshis) { if (satoshis >= 100000000) { return `${(satoshis / 100000000).toFixed(4)} BSV`; } return `${satoshis.toLocaleString()} sats`; } export function SyncAssetsComponent({ onSyncComplete, onError, maxFileSize = 10 * 1024 * 1024, // 10MB maxFiles = 50, acceptedTypes = [ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".pdf", ".txt", ".json", ".md", ".html", ], estimatedFeePerKB = 500, className = "", }) { const [files, setFiles] = useState([]); const [isChecking, setIsChecking] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [error, setError] = useState(null); const [syncSummary, setSyncSummary] = useState(null); // Handle file drop const handleFiles = useCallback(async (fileList) => { setError(null); const newFiles = Array.from(fileList); // Validate file count if (files.length + newFiles.length > maxFiles) { setError(`Cannot add more than ${maxFiles} files. Remove some files first.`); return; } // Validate and create SyncFile objects const validFiles = []; for (const file of newFiles) { // Check file size if (file.size > maxFileSize) { setError(`File "${file.name}" is too large. Maximum size is ${formatFileSize(maxFileSize)}.`); continue; } // Check file type const fileExtension = `.${file.name.split(".").pop()?.toLowerCase()}`; if (!acceptedTypes.includes(fileExtension)) { setError(`File type "${fileExtension}" is not supported.`); continue; } // Check for duplicates const isDuplicate = files.some((f) => f.file.name === file.name && f.file.size === file.size); if (isDuplicate) { setError(`File "${file.name}" is already in the list.`); continue; } validFiles.push({ id: crypto.randomUUID(), file, status: FileSyncStatus.Pending, size: file.size, estimatedFee: estimateTransactionFee(file.size, estimatedFeePerKB), }); } if (validFiles.length > 0) { setFiles((prev) => [...prev, ...validFiles]); } }, [files, maxFiles, maxFileSize, acceptedTypes, estimatedFeePerKB]); // Remove file from list const removeFile = useCallback((fileId) => { setFiles((prev) => prev.filter((f) => f.id !== fileId)); setError(null); }, []); // Clear all files const clearAllFiles = useCallback(() => { setFiles([]); setError(null); setSyncSummary(null); }, []); // Check files on blockchain const checkFiles = useCallback(async () => { if (files.length === 0) return; setIsChecking(true); setError(null); try { const updatedFiles = []; for (const file of files) { // Skip files that are already checked if (file.status !== FileSyncStatus.Pending) { updatedFiles.push(file); continue; } // Update status to checking const checkingFile = { ...file, status: FileSyncStatus.Checking }; setFiles((prev) => prev.map((f) => (f.id === file.id ? checkingFile : f))); try { // Calculate file hash const hash = await calculateFileHash(file.file); // Check if file exists on blockchain const { exists, txid } = await checkFileOnChain(hash); const updatedFile = { ...checkingFile, hash, status: exists ? FileSyncStatus.OnChain : FileSyncStatus.NeedsSync, txid: exists ? txid : undefined, }; updatedFiles.push(updatedFile); setFiles((prev) => prev.map((f) => (f.id === file.id ? updatedFile : f))); } catch (err) { const failedFile = { ...checkingFile, status: FileSyncStatus.Failed, error: err instanceof Error ? err.message : "Failed to process file", }; updatedFiles.push(failedFile); setFiles((prev) => prev.map((f) => (f.id === file.id ? failedFile : f))); } } // Calculate sync summary const needsSyncFiles = updatedFiles.filter((f) => f.status === FileSyncStatus.NeedsSync); const summary = { totalFiles: needsSyncFiles.length, needsSyncCount: needsSyncFiles.length, totalSize: needsSyncFiles.reduce((sum, f) => sum + f.size, 0), estimatedTotalFee: needsSyncFiles.reduce((sum, f) => sum + (f.estimatedFee || 0), 0), }; setSyncSummary(summary); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to check files"; setError(errorMessage); onError?.(errorMessage); } finally { setIsChecking(false); } }, [files, onError]); // Sync files to blockchain const syncFiles = useCallback(async () => { const filesToSync = files.filter((f) => f.status === FileSyncStatus.NeedsSync); if (filesToSync.length === 0) return; setIsSyncing(true); setError(null); const results = { success: [], failed: [] }; try { for (let i = 0; i < filesToSync.length; i++) { const file = filesToSync[i]; if (!file) continue; // TypeScript guard // Update status to syncing setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, status: FileSyncStatus.Syncing, progress: 0 } : f)); try { // Simulate blockchain sync progress for (let progress = 0; progress <= 100; progress += 10) { setFiles((prev) => prev.map((f) => (f.id === file.id ? { ...f, progress } : f))); // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 200)); } // TODO: Implement actual blockchain sync // This would involve: // 1. Creating a transaction with the file data // 2. Broadcasting it to the network // 3. Getting the transaction ID // For now, simulate successful sync const mockTxid = crypto .randomUUID() .replace(/-/g, "") .substring(0, 64); const syncedFile = { ...file, status: FileSyncStatus.Synced, txid: mockTxid, progress: 100, }; setFiles((prev) => prev.map((f) => (f.id === file.id ? syncedFile : f))); results.success.push(syncedFile); } catch (err) { const failedFile = { ...file, status: FileSyncStatus.Failed, error: err instanceof Error ? err.message : "Failed to sync file", }; setFiles((prev) => prev.map((f) => (f.id === file.id ? failedFile : f))); results.failed.push(failedFile); } } onSyncComplete?.(results); } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to sync files"; setError(errorMessage); onError?.(errorMessage); } finally { setIsSyncing(false); } }, [files, onSyncComplete, onError]); // Get status icon const getStatusIcon = (status) => { switch (status) { case FileSyncStatus.Pending: return (_jsx(ClockIcon, { width: "16", height: "16", style: { color: "var(--muted-foreground)" } })); case FileSyncStatus.Checking: return (_jsx(UpdateIcon, { width: "16", height: "16", className: "animate-spin", style: { color: "var(--primary-foreground)" } })); case FileSyncStatus.OnChain: return (_jsx(CheckCircledIcon, { width: "16", height: "16", style: { color: "var(--primary-foreground)" } })); case FileSyncStatus.NeedsSync: return (_jsx(InfoCircledIcon, { width: "16", height: "16", style: { color: "var(--destructive-foreground)" } })); case FileSyncStatus.Syncing: return (_jsx(UpdateIcon, { width: "16", height: "16", className: "animate-spin", style: { color: "var(--primary-foreground)" } })); case FileSyncStatus.Synced: return (_jsx(CheckCircledIcon, { width: "16", height: "16", style: { color: "var(--primary-foreground)" } })); case FileSyncStatus.Failed: return (_jsx(CrossCircledIcon, { width: "16", height: "16", style: { color: "var(--destructive-foreground)" } })); } }; // Get status color const getStatusColor = (status) => { switch (status) { case FileSyncStatus.Pending: return "gray"; case FileSyncStatus.Checking: case FileSyncStatus.Syncing: return "blue"; case FileSyncStatus.OnChain: case FileSyncStatus.Synced: return "green"; case FileSyncStatus.NeedsSync: return "amber"; case FileSyncStatus.Failed: return "red"; } }; // Get status text const getStatusText = (file) => { switch (file.status) { case FileSyncStatus.Pending: return "Pending"; case FileSyncStatus.Checking: return "Checking..."; case FileSyncStatus.OnChain: return "On Chain"; case FileSyncStatus.NeedsSync: return "Needs Sync"; case FileSyncStatus.Syncing: return `Syncing... ${file.progress || 0}%`; case FileSyncStatus.Synced: return "Synced"; case FileSyncStatus.Failed: return file.error || "Failed"; } }; const hasFiles = files.length > 0; const hasPendingFiles = files.some((f) => f.status === FileSyncStatus.Pending); const hasFilesToSync = files.some((f) => f.status === FileSyncStatus.NeedsSync); const isProcessing = isChecking || isSyncing; return (_jsxs("div", { className: cn("flex flex-col gap-6", className), children: [_jsx(DropZone, { multiple: true, accept: acceptedTypes.join(","), maxSize: maxFileSize, disabled: isProcessing, icon: _jsx(FileIcon, {}), title: "Sync Files to Blockchain", description: `Drop files here or click to browse (max ${maxFiles} files, ${formatFileSize(maxFileSize)} each)`, dragActiveText: "Drop files here", dragActiveDescription: "Release to add files", height: 150, onDrop: handleFiles, onError: setError }), error && _jsx(ErrorDisplay, { error: error }), hasFiles && (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "text-lg font-medium", children: ["Files (", files.length, ")"] }), _jsx(Button, { variant: "ghost", size: "sm", onClick: clearAllFiles, disabled: isProcessing, children: "Clear All" })] }), _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "File" }), _jsx(TableHead, { children: "Size" }), _jsx(TableHead, { children: "Status" }), _jsx(TableHead, { children: "Fee" }), _jsx(TableHead, { children: "Action" })] }) }), _jsx(TableBody, { children: files.map((file) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FileIcon, { width: "16", height: "16" }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: file.file.name }), file.hash && (_jsxs("p", { className: "text-xs text-muted-foreground font-mono", children: [file.hash.substring(0, 16), "..."] }))] })] }) }), _jsx(TableCell, { children: _jsx("p", { className: "text-sm", children: formatFileSize(file.size) }) }), _jsxs(TableCell, { children: [_jsxs("div", { className: "flex items-center gap-2", children: [getStatusIcon(file.status), _jsx(Badge, { variant: "secondary", children: getStatusText(file) })] }), file.status === FileSyncStatus.Syncing && file.progress !== undefined && (_jsx("div", { className: "mt-1 w-20", children: _jsx(Progress, { value: file.progress, className: "h-1" }) }))] }), _jsx(TableCell, { children: file.estimatedFee && (_jsx("p", { className: "text-sm", children: formatSatoshis(file.estimatedFee) })) }), _jsx(TableCell, { children: _jsx(TooltipProvider, { children: _jsxs("div", { className: "flex items-center gap-1", children: [file.txid && (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", onClick: () => window.open(`https://whatsonchain.com/tx/${file.txid}`, "_blank"), children: _jsx(ExternalLinkIcon, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: "View on blockchain" })] })), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Button, { variant: "ghost", size: "icon", onClick: () => removeFile(file.id), disabled: isProcessing, className: "text-destructive hover:text-destructive", children: _jsx(TrashIcon, { className: "h-3.5 w-3.5" }) }) }), _jsx(TooltipContent, { children: "Remove file" })] })] }) }) })] }, file.id))) })] })] }) }) })), hasFiles && (_jsxs("div", { className: "flex gap-3 justify-end", children: [_jsx(LoadingButton, { onClick: checkFiles, loading: isChecking, disabled: !hasPendingFiles || isSyncing, variant: "outline", children: "Check Blockchain Status" }), syncSummary && (_jsxs(LoadingButton, { onClick: syncFiles, loading: isSyncing, disabled: !hasFilesToSync || isChecking, variant: "default", children: ["Sync ", syncSummary.needsSyncCount, " File", syncSummary.needsSyncCount !== 1 ? "s" : ""] }))] })), syncSummary && syncSummary.needsSyncCount > 0 && (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Sync Summary" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex justify-between", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: "Files to Sync" }), _jsx("span", { className: "text-sm font-medium", children: syncSummary.needsSyncCount })] }), _jsxs("div", { className: "flex justify-between", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: "Total Size" }), _jsx("span", { className: "text-sm font-medium", children: formatFileSize(syncSummary.totalSize) })] }), _jsxs("div", { className: "flex justify-between", children: [_jsx("span", { className: "text-sm text-muted-foreground", children: "Estimated Fee" }), _jsx("span", { className: "text-sm font-medium", children: formatSatoshis(syncSummary.estimatedTotalFee) })] })] })] }))] })); }