bigblocks
Version:
Complete Bitcoin UI component library - authentication, social, wallet, market, inscriptions, and blockchain interactions for React
342 lines (341 loc) • 19.3 kB
JavaScript
"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) })] })] })] }))] }));
}