diff --git a/packages/demo/package.json b/packages/demo/package.json index d4c1ce1a..44c89c69 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -28,6 +28,7 @@ "@ckb-ccc/connector-react": "workspace:*", "@ckb-ccc/lumos-patches": "workspace:*", "@ckb-ccc/ssri": "workspace:*", + "@ckb-ccc/type-id": "workspace:*", "@ckb-ccc/udt": "workspace:*", "@ckb-lumos/ckb-indexer": "0.24.0-next.2", "@ckb-lumos/common-scripts": "0.24.0-next.2", diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx b/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx new file mode 100644 index 00000000..ed9b804e --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/deployComponents.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { Button } from "@/src/components/Button"; +import { Message } from "@/src/components/Message"; +import { useGetExplorerLink } from "@/src/utils"; +import { ccc } from "@ckb-ccc/connector-react"; +import { FileCode, X } from "lucide-react"; +import type { DeployResult } from "./deployLogic"; + +function formatCellCreationDate(timestampMs: number): string { + try { + return new Date(timestampMs).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return ""; + } +} + +export function TypeIdCellListItem({ + cell, + index, + onSelect, + isSelected, + creationTimestamp, +}: { + cell: ccc.Cell; + index: number; + onSelect: () => void; + isSelected: boolean; + creationTimestamp?: number; +}) { + const outPoint = `${cell.outPoint.txHash}:${cell.outPoint.index}`; + + return ( + + ); +} + +export function CellFoundSection({ + foundCell, + onClear, +}: { + foundCell: ccc.Cell; + onClear: () => void; +}) { + const { explorerTransaction } = useGetExplorerLink(); + const typeScript = foundCell.cellOutput.type; + const typeId = typeScript?.args; + + return ( +
+
+
+ +

Cell to Update

+
+ +
+
+

+ Out Point:{" "} + {explorerTransaction( + foundCell.outPoint.txHash, + `${foundCell.outPoint.txHash}:${foundCell.outPoint.index}`, + )} +

+

+ Occupied / Capacity:{" "} + {ccc.fixedPointToString(ccc.fixedPointFrom(foundCell.occupiedSize))} /{" "} + {ccc.fixedPointToString(foundCell.cellOutput.capacity)} CKB +

+

+ Data Hash:{" "} + + {ccc.hashCkb(foundCell.outputData ?? "0x")} + +

+ {typeScript && ( +

+ Type Hash:{" "} + {typeScript.hash()} +

+ )} + {typeId && typeId !== "0x" && ( +

+ Type ID:{" "} + {typeId} +

+ )} +
+
+ ); +} + +export function DeploymentResultSection({ + result, +}: { + result: DeployResult & { + immutable: boolean; + action: "deployed" | "updated"; + }; +}) { + const { explorerTransaction } = useGetExplorerLink(); + const outPoint = `${result.txHash}:0`; + + return ( + +
+

+ Out Point:{" "} + {explorerTransaction(result.txHash, outPoint)} +

+

+ Type ID:{" "} + {result.typeId} +

+

+ Data Hash:{" "} + {result.dataHash} +

+ {result.immutable && ( +

+ This cell is immutable and can never be updated. +

+ )} +
+
+ ); +} + +export function BurnButton({ + onClick, + disabled, +}: { + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts b/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts new file mode 100644 index 00000000..79f85408 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/deployLogic.ts @@ -0,0 +1,81 @@ +import { readFileAsBytes } from "@/src/app/utils/(tools)/FileUpload/page"; +import { ccc } from "@ckb-ccc/connector-react"; +import { createTypeId, transferTypeId } from "@ckb-ccc/type-id"; +import { ReactNode } from "react"; +import { createImmutableLock } from "./helpers"; + +export type Logger = (...args: ReactNode[]) => void; +export type DeployResult = { + txHash: string; + typeId: string; + dataHash: string; +}; + +export async function runDeploy( + signer: ccc.Signer, + file: File, + immutable: boolean, + foundCell: ccc.Cell | null, + log: Logger, +): Promise { + const fileBytes = (await readFileAsBytes(file)) as ccc.Bytes; + + let tx: ccc.Transaction; + let typeIdArgsValue: string; + + if (foundCell) { + const typeId = foundCell.cellOutput.type?.args; + if (!typeId) { + throw new Error("Selected cell does not have a Type ID"); + } + log("Updating existing Type ID cell..."); + + ({ tx } = await transferTypeId({ + client: signer.client, + id: typeId, + receiver: immutable ? createImmutableLock() : foundCell.cellOutput.lock, + data: fileBytes, + })); + typeIdArgsValue = typeId; + } else { + log("Building transaction..."); + const created = await createTypeId({ + signer, + data: fileBytes, + receiver: immutable ? createImmutableLock() : undefined, + }); + tx = created.tx; + typeIdArgsValue = created.id; + log("Type ID created:", typeIdArgsValue); + } + + await tx.completeFeeBy(signer); + log("Sending transaction..."); + const txHash = await signer.sendTransaction(tx); + log("Transaction sent:", txHash); + return { + txHash, + typeId: typeIdArgsValue, + dataHash: ccc.hashCkb(fileBytes), + }; +} + +/** Burn the selected type_id cell: consume it and send capacity back to the lock (no type script). */ +export async function runBurn( + signer: ccc.Signer, + foundCell: ccc.Cell, + log: Logger, +): Promise { + const { lock } = foundCell.cellOutput; + const tx = ccc.Transaction.from({ + inputs: [{ previousOutput: foundCell.outPoint }], + outputs: [{ lock, capacity: ccc.Zero }], + outputsData: ["0x"], + }); + await tx.addCellDepsOfKnownScripts(signer.client, ccc.KnownScript.TypeId); + await tx.completeFeeChangeToOutput(signer, 0); + log("Sending burn transaction..."); + const txHash = await signer.sendTransaction(tx); + log("Transaction sent:", txHash); + return txHash; +} diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts b/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts new file mode 100644 index 00000000..86d94dec --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/helpers.ts @@ -0,0 +1,16 @@ +import { ccc } from "@ckb-ccc/connector-react"; + +/** Normalize Type ID args (strip 0x, trim). */ +export function normalizeTypeIdArgs(args: string): string { + const s = (args || "").trim(); + return s.startsWith("0x") ? s.slice(2) : s; +} + +/** Create an unspendable lock script for immutable cells. */ +export function createImmutableLock(): ccc.Script { + return ccc.Script.from({ + codeHash: `0x${"00".repeat(32)}`, + hashType: "data", + args: "0x", + }); +} diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx b/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx new file mode 100644 index 00000000..31abcc68 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/page.tsx @@ -0,0 +1,345 @@ +"use client"; + +import FileUploadArea from "@/src/app/utils/(tools)/FileUpload/page"; +import { Button } from "@/src/components/Button"; +import { ButtonsPanel } from "@/src/components/ButtonsPanel"; +import { Message } from "@/src/components/Message"; +import { useApp } from "@/src/context"; +import { useGetExplorerLink } from "@/src/utils"; +import { ccc } from "@ckb-ccc/connector-react"; +import { RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + BurnButton, + CellFoundSection, + DeploymentResultSection, + TypeIdCellListItem, +} from "./deployComponents"; +import { runBurn, runDeploy, type DeployResult } from "./deployLogic"; +import { createImmutableLock } from "./helpers"; +import { useDeployScript } from "./useDeployScript"; + +export default function DeployScript() { + const { createSender } = useApp(); + const { log, error } = createSender("Deploy Script"); + const { explorerTransaction } = useGetExplorerLink(); + + const [file, setFile] = useState(null); + const [immutable, setImmutable] = useState(false); + const [operation, setOperation] = useState< + "deploy" | "update" | "burn" | null + >(null); + const [lastDeployment, setLastDeployment] = useState< + | (DeployResult & { immutable: boolean; action: "deployed" | "updated" }) + | null + >(null); + const [newCellOccupiedSizes, setNewCellOccupiedSizes] = useState<{ + signer: ccc.Signer; + ownedBaseSize: number | null; + immutableBaseSize: number | null; + } | null>(null); + const fileInputRef = useRef(null); + const refreshTimersRef = useRef[]>([]); + + const { + signer, + typeIdArgs, + typeIdCells, + cellCreationTimestamps, + isScanningCells, + isLoadingMoreCells, + hasMoreTypeIdCells, + cellScanError, + foundCell, + handleSelectTypeIdCell, + clearSelection, + normalizeTypeIdArgs, + refreshTypeIdCells, + loadMoreTypeIdCells, + } = useDeployScript(); + + const isDeploying = operation !== null; + + const refreshCellsAfterTransaction = useCallback(() => { + refreshTimersRef.current.forEach(clearTimeout); + refreshTypeIdCells(); + refreshTimersRef.current = [ + setTimeout(refreshTypeIdCells, 1500), + setTimeout(refreshTypeIdCells, 4000), + ]; + }, [refreshTypeIdCells]); + + useEffect(() => () => refreshTimersRef.current.forEach(clearTimeout), []); + + useEffect(() => { + if (!signer) return; + let cancelled = false; + + (async () => { + try { + const [{ script: lock }, type] = await Promise.all([ + signer.getRecommendedAddressObj(), + ccc.Script.fromKnownScript( + signer.client, + ccc.KnownScript.TypeId, + "00".repeat(32), + ), + ]); + const ownedBaseSize = ccc.CellOutput.from({ lock, type }).occupiedSize; + const immutableBaseSize = ccc.CellOutput.from({ + lock: createImmutableLock(), + type, + }).occupiedSize; + if (!cancelled) { + setNewCellOccupiedSizes({ + signer, + ownedBaseSize, + immutableBaseSize, + }); + } + } catch { + if (!cancelled) { + setNewCellOccupiedSizes({ + signer, + ownedBaseSize: null, + immutableBaseSize: null, + }); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [signer]); + + const handleBurn = useCallback(async () => { + if (!signer || !foundCell) return; + setOperation("burn"); + setLastDeployment(null); + try { + const txHash = await runBurn(signer, foundCell, log); + if (!txHash) return; + log("Transaction sent:", explorerTransaction(txHash)); + await signer.client.waitTransaction(txHash); + log("Transaction committed:", explorerTransaction(txHash)); + clearSelection(); + refreshCellsAfterTransaction(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error("Burn failed:", msg); + } finally { + setOperation(null); + } + }, [ + signer, + foundCell, + log, + error, + explorerTransaction, + clearSelection, + refreshCellsAfterTransaction, + ]); + + const handleDeploy = useCallback(async () => { + if (!signer) { + error("Please connect a wallet first"); + return; + } + if (!file) { + error("Please select a file to deploy"); + return; + } + + const action = foundCell ? "updated" : "deployed"; + setOperation(foundCell ? "update" : "deploy"); + setLastDeployment(null); + try { + log("Reading file..."); + const result = await runDeploy(signer, file, immutable, foundCell, log); + + const { txHash } = result; + + log("Transaction sent:", explorerTransaction(txHash)); + await signer.client.waitTransaction(txHash); + log("Transaction committed:", explorerTransaction(txHash)); + setLastDeployment({ ...result, immutable, action }); + if (foundCell) clearSelection(); + refreshCellsAfterTransaction(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + error("Deployment failed:", msg); + } finally { + setOperation(null); + } + }, [ + signer, + file, + immutable, + foundCell, + log, + error, + explorerTransaction, + clearSelection, + refreshCellsAfterTransaction, + ]); + + const normalizedInput = normalizeTypeIdArgs(typeIdArgs); + const baseOccupiedSize = (() => { + if (foundCell) { + if (!immutable) return foundCell.cellOutput.occupiedSize; + return ccc.CellOutput.from({ + lock: createImmutableLock(), + type: foundCell.cellOutput.type, + }).occupiedSize; + } + if (newCellOccupiedSizes && newCellOccupiedSizes.signer === signer) { + return immutable + ? newCellOccupiedSizes.immutableBaseSize + : newCellOccupiedSizes.ownedBaseSize; + } + return undefined; + })(); + const toOccupy = !file + ? undefined + : baseOccupiedSize === null + ? "Unavailable" + : baseOccupiedSize === undefined + ? signer + ? "Calculating..." + : "Connect wallet to calculate" + : `${ccc.fixedPointToString( + ccc.fixedPointFrom(baseOccupiedSize + file.size), + )} CKB`; + const deployButtonLabel = + operation === "update" + ? "Updating..." + : operation === "deploy" + ? "Deploying..." + : !file + ? "Select File" + : typeIdArgs + ? "Update" + : "Deploy"; + + return ( +
+ + Upload a file to deploy it as a CKB cell with Type ID trait. The file + will be stored on-chain and can be referenced by its Type ID. Select an + existing Type ID cell below to update it, or leave all cells unselected + to create a new one. + + + setImmutable((value) => !value)} + > + {foundCell && ( + + )} + + + {lastDeployment && } + + {cellScanError && ( + +
+

{cellScanError}

+ +
+
+ )} + +
+
+

+ Update Existing Cell +

+ +
+ + {!isScanningCells && !cellScanError && typeIdCells.length === 0 && ( +

+ No existing Type ID cells found. +

+ )} + + {typeIdCells.length > 0 && ( + <> +
+
+ {typeIdCells.map((cell, index) => { + const cellNorm = normalizeTypeIdArgs( + cell.cellOutput.type?.args || "", + ); + const isSelected = + cellNorm === normalizedInput && normalizedInput !== ""; + + return ( + handleSelectTypeIdCell(cell)} + isSelected={isSelected} + creationTimestamp={ + cellCreationTimestamps[ + ccc.hexFrom(cell.outPoint.toBytes()) + ] + } + /> + ); + })} +
+
+ {hasMoreTypeIdCells && ( +
+ +
+ )} + + )} +
+ + + + {typeIdArgs && ( + + )} + +
+ ); +} diff --git a/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts b/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts new file mode 100644 index 00000000..ebaa48c1 --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/DeployScript/useDeployScript.ts @@ -0,0 +1,231 @@ +"use client"; + +import { useApp } from "@/src/context"; +import { ccc } from "@ckb-ccc/connector-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { normalizeTypeIdArgs } from "./helpers"; + +const TYPE_ID_PAGE_SIZE = 8; + +async function takeCells( + iterator: AsyncGenerator, + count: number, +): Promise { + const cells: ccc.Cell[] = []; + while (cells.length < count) { + const next = await iterator.next(); + if (next.done) break; + cells.push(next.value); + } + return cells; +} + +async function prepareCellPage(client: ccc.Client, cells: ccc.Cell[]) { + const metadata = await Promise.all( + cells.map(async (cell) => { + const key = ccc.hexFrom(cell.outPoint.toBytes()); + try { + const res = await client.getCellWithHeader(cell.outPoint); + if (!res?.header) return { key }; + return { + key, + blockNumber: res.header.number, + timestamp: Number(res.header.timestamp), + }; + } catch { + return { key }; + } + }), + ); + const metadataByKey = new Map(metadata.map((item) => [item.key, item])); + const timestamps: Record = {}; + + for (const item of metadata) { + if (item.timestamp != null) timestamps[item.key] = item.timestamp; + } + + return { + cells: [...cells].sort((a, b) => { + const aBlock = metadataByKey.get( + ccc.hexFrom(a.outPoint.toBytes()), + )?.blockNumber; + const bBlock = metadataByKey.get( + ccc.hexFrom(b.outPoint.toBytes()), + )?.blockNumber; + if (aBlock == null && bBlock == null) return 0; + if (aBlock == null) return 1; + if (bBlock == null) return -1; + if (aBlock === bBlock) return 0; + return bBlock > aBlock ? 1 : -1; + }), + timestamps, + }; +} + +export function useDeployScript() { + const { signer } = useApp(); + + const [typeIdArgs, setTypeIdArgs] = useState(""); + const [typeIdCells, setTypeIdCells] = useState([]); + const [isScanningCells, setIsScanningCells] = useState(false); + const [isLoadingMoreCells, setIsLoadingMoreCells] = useState(false); + const [hasMoreTypeIdCells, setHasMoreTypeIdCells] = useState(false); + const [bufferedTypeIdCell, setBufferedTypeIdCell] = useState( + null, + ); + const [cellScanError, setCellScanError] = useState(""); + const [foundCell, setFoundCell] = useState(null); + const [cellCreationTimestamps, setCellCreationTimestamps] = useState< + Record + >({}); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const scanGenerationRef = useRef(0); + const activeSignerRef = useRef(undefined); + const cellIteratorRef = useRef | null>(null); + + // Scan Type ID cells (runs on signer change or force refresh) + const refreshTypeIdCells = useCallback(() => { + setRefreshTrigger((t) => t + 1); + }, []); + + useEffect(() => { + const generation = ++scanGenerationRef.current; + let cancelled = false; + let iterator: AsyncGenerator | null = null; + (async () => { + await Promise.resolve(); + if (cancelled) return; + + const signerChanged = activeSignerRef.current !== signer; + activeSignerRef.current = signer; + setIsLoadingMoreCells(false); + setIsScanningCells(false); + setCellScanError(""); + + if (signerChanged) { + setTypeIdCells([]); + setBufferedTypeIdCell(null); + setHasMoreTypeIdCells(false); + setCellCreationTimestamps({}); + setTypeIdArgs(""); + setFoundCell(null); + } + + if (!signer) return; + setIsScanningCells(true); + try { + const { script: lock } = await signer.getRecommendedAddressObj(); + const typeIdScript = await ccc.Script.fromKnownScript( + signer.client, + ccc.KnownScript.TypeId, + "", + ); + iterator = signer.client.findCells( + { + script: typeIdScript, + scriptType: "type", + scriptSearchMode: "prefix", + withData: true, + filter: { script: lock }, + }, + "desc", + TYPE_ID_PAGE_SIZE + 1, + ); + cellIteratorRef.current = iterator; + const cells = await takeCells(iterator, TYPE_ID_PAGE_SIZE + 1); + const page = await prepareCellPage( + signer.client, + cells.slice(0, TYPE_ID_PAGE_SIZE), + ); + if (cancelled || scanGenerationRef.current !== generation) return; + setCellCreationTimestamps(page.timestamps); + setTypeIdCells(page.cells); + setBufferedTypeIdCell(cells[TYPE_ID_PAGE_SIZE] ?? null); + setHasMoreTypeIdCells(cells.length > TYPE_ID_PAGE_SIZE); + } catch (err) { + if (cancelled || scanGenerationRef.current !== generation) return; + const msg = err instanceof Error ? err.message : String(err); + setCellScanError(`Failed to load Type ID cells: ${msg}`); + } finally { + if (!cancelled && scanGenerationRef.current === generation) { + setIsScanningCells(false); + } + } + })(); + return () => { + cancelled = true; + if (cellIteratorRef.current === iterator) { + cellIteratorRef.current = null; + } + if (iterator) void iterator.return(undefined); + }; + }, [signer, refreshTrigger]); + + const loadMoreTypeIdCells = useCallback(async () => { + if ( + !signer || + !cellIteratorRef.current || + !bufferedTypeIdCell || + !hasMoreTypeIdCells || + isLoadingMoreCells + ) { + return; + } + + setIsLoadingMoreCells(true); + setCellScanError(""); + const generation = scanGenerationRef.current; + const iterator = cellIteratorRef.current; + try { + const cells = await takeCells(iterator, TYPE_ID_PAGE_SIZE); + const candidates = [bufferedTypeIdCell, ...cells]; + const page = await prepareCellPage( + signer.client, + candidates.slice(0, TYPE_ID_PAGE_SIZE), + ); + if (scanGenerationRef.current !== generation) return; + setTypeIdCells((current) => [...current, ...page.cells]); + setCellCreationTimestamps((current) => ({ + ...current, + ...page.timestamps, + })); + setBufferedTypeIdCell(candidates[TYPE_ID_PAGE_SIZE] ?? null); + setHasMoreTypeIdCells(candidates.length > TYPE_ID_PAGE_SIZE); + } catch (err) { + if (scanGenerationRef.current !== generation) return; + const msg = err instanceof Error ? err.message : String(err); + setCellScanError(`Failed to load more Type ID cells: ${msg}`); + } finally { + if (scanGenerationRef.current === generation) { + setIsLoadingMoreCells(false); + } + } + }, [signer, bufferedTypeIdCell, hasMoreTypeIdCells, isLoadingMoreCells]); + + const handleSelectTypeIdCell = useCallback((cell: ccc.Cell) => { + setTypeIdArgs(cell.cellOutput.type?.args || ""); + setFoundCell(cell); + }, []); + + const clearSelection = useCallback(() => { + setTypeIdArgs(""); + setFoundCell(null); + }, []); + + return { + signer, + typeIdArgs, + typeIdCells, + cellCreationTimestamps, + isScanningCells, + isLoadingMoreCells, + hasMoreTypeIdCells, + cellScanError, + foundCell, + handleSelectTypeIdCell, + clearSelection, + normalizeTypeIdArgs, + refreshTypeIdCells, + loadMoreTypeIdCells, + }; +} diff --git a/packages/demo/src/app/connected/page.tsx b/packages/demo/src/app/connected/page.tsx index fd3facab..a3ee824e 100644 --- a/packages/demo/src/app/connected/page.tsx +++ b/packages/demo/src/app/connected/page.tsx @@ -46,6 +46,7 @@ const TABS: [ReactNode, string, keyof typeof icons, string][] = [ "text-cyan-600", ], ["Nervos DAO", "/connected/NervosDao", "Vault", "text-pink-500"], + ["Deploy Script", "/connected/DeployScript", "Upload", "text-purple-500"], ["Dep Group", "/utils/DepGroup", "Boxes", "text-amber-500"], ["SSRI", "/connected/SSRI", "Pill", "text-blue-500"], ["Hash", "/utils/Hash", "Barcode", "text-violet-500"], diff --git a/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx b/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx new file mode 100644 index 00000000..f32e8192 --- /dev/null +++ b/packages/demo/src/app/utils/(tools)/FileUpload/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { ccc } from "@ckb-ccc/connector-react"; +import { ArrowLeftRight, Upload, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +export async function readFileAsBytes(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result instanceof ArrayBuffer) { + resolve(new Uint8Array(e.target.result)); + } else { + reject(new Error("Failed to read file")); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsArrayBuffer(file); + }); +} + +function FileDataHash({ file }: { file: File }) { + const [dataHash, setDataHash] = useState(null); + + useEffect(() => { + let cancelled = false; + + readFileAsBytes(file) + .then((bytes) => { + if (!cancelled) setDataHash(ccc.hashCkb(bytes)); + }) + .catch(() => { + if (!cancelled) setDataHash("Unavailable"); + }); + + return () => { + cancelled = true; + }; + }, [file]); + + return ( +

+ Data Hash:{" "} + + {dataHash ?? "Calculating..."} + +

+ ); +} + +export default function FileUploadArea({ + file, + onFileChange, + fileInputRef: externalFileInputRef, + toOccupy, + immutable = false, + onImmutableChange, + children, +}: { + file: File | null; + onFileChange: (file: File | null) => void; + fileInputRef?: React.RefObject; + toOccupy?: string; + immutable?: boolean; + onImmutableChange?: () => void; + children?: React.ReactNode; +}) { + const [isDragging, setIsDragging] = useState(false); + const internalFileInputRef = useRef(null); + const fileInputRef = externalFileInputRef ?? internalFileInputRef; + + const handleFileSelect = (selectedFile: File) => { + onFileChange(selectedFile); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile) { + handleFileSelect(droppedFile); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (file || (e.key !== "Enter" && e.key !== " ")) return; + e.preventDefault(); + fileInputRef.current?.click(); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + handleFileSelect(selectedFile); + } + }; + + const handleClearFile = () => { + onFileChange(null); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+ {children && ( +
+ {children} +
+ )} + + e.stopPropagation()} + onChange={handleFileInputChange} + /> + + {!file ? ( +
fileInputRef.current?.click()} + onKeyDown={handleKeyDown} + role="button" + tabIndex={0} + aria-label="Select file" + > + +
+

+ Drag and drop a file here, or click to select +

+
+
+ ) : ( +
+
+
+ +
+

+ To Occupy:{" "} + {toOccupy ?? "Calculating..."} +

+ + {immutable && ( +

+ This cell will become immutable and can never be updated. +

+ )} +
+
+ +
+ {onImmutableChange && ( +
+ + Options: + + +
+ )} +
+ )} +
+ ); +} diff --git a/packages/demo/src/components/Message.tsx b/packages/demo/src/components/Message.tsx index 34092172..35429aa8 100644 --- a/packages/demo/src/components/Message.tsx +++ b/packages/demo/src/components/Message.tsx @@ -7,6 +7,7 @@ export interface MessageProps { type?: "error" | "warning" | "info" | "success"; lines?: number; className?: string; + expandable?: boolean; } export function Message({ @@ -15,8 +16,9 @@ export function Message({ type = "info", lines, className = "", + expandable = true, }: MessageProps) { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(!expandable); let colorClass = ""; let bgColorClass = ""; @@ -41,10 +43,12 @@ export function Message({ break; } + const showFull = expandable ? isExpanded : true; + return (
setIsExpanded(!isExpanded)} - className={`my-2 flex cursor-pointer flex-col items-start rounded-md p-4 ${bgColorClass} ${className}`} + onClick={expandable ? () => setIsExpanded(!isExpanded) : undefined} + className={`my-2 flex flex-col items-start rounded-md p-4 ${bgColorClass} ${className} ${expandable ? "cursor-pointer" : ""}`} > {title ? (
@@ -57,18 +61,18 @@ export function Message({
) : undefined}
-

+

{children} -

+
); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a974eb29..37d9f76f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,6 +329,9 @@ importers: '@ckb-ccc/ssri': specifier: workspace:* version: link:../ssri + '@ckb-ccc/type-id': + specifier: workspace:* + version: link:../type-id '@ckb-ccc/udt': specifier: workspace:* version: link:../udt