* feat: add file manager module - Complete file manager implementation with UI/UX - Add drive management functionality - Add file upload/download with progress tracking - Add stamp integration and handling - Add bulk operations and context menus Co-authored-by: Roland Seres <roland.seres90@gmail.com> Co-authored-by: nidishk <nidishkrishnan45@gmail.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { ReactElement } from 'react'
|
||||
import ImageIcon from 'remixicon-react/Image2LineIcon'
|
||||
import FileIcon from 'remixicon-react/FileTextLineIcon'
|
||||
|
||||
interface ContextMenuProps {
|
||||
icon: string
|
||||
size?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function GetIconElement({ icon, size = '21px', color = '#ed8131' }: ContextMenuProps): ReactElement {
|
||||
switch (icon) {
|
||||
case 'image':
|
||||
return <ImageIcon size={size} color={color} />
|
||||
default:
|
||||
return <FileIcon size={size} color={color} />
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export class AbortManager {
|
||||
private controllers = new Map<string, AbortController>()
|
||||
|
||||
create(key: string): AbortController | undefined {
|
||||
if (!this.controllers.has(key)) {
|
||||
this.controllers.set(key, new AbortController())
|
||||
}
|
||||
|
||||
return this.controllers.get(key)
|
||||
}
|
||||
|
||||
getSignal(key: string): AbortSignal | undefined {
|
||||
return this.controllers.get(key)?.signal
|
||||
}
|
||||
|
||||
abort(key: string): void {
|
||||
const controller = this.controllers.get(key)
|
||||
controller?.abort()
|
||||
this.controllers.delete(key)
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.controllers.has(key)
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.controllers.forEach(controller => controller.abort())
|
||||
this.controllers.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { BatchId, Bee, BZZ, Duration, PostageBatch, RedundancyLevel, Size } from '@ethersphere/bee-js'
|
||||
import { FileManagerBase, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { getHumanReadableFileSize } from '../../../utils/file'
|
||||
|
||||
export const getUsableStamps = async (bee: Bee | null): Promise<PostageBatch[]> => {
|
||||
if (!bee) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
return (await bee.getPostageBatches())
|
||||
.filter(s => s.usable)
|
||||
.sort((a, b) => (a.label || '').localeCompare(b.label || ''))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const fmGetStorageCost = async (
|
||||
capacity: number,
|
||||
validityEndDate: Date,
|
||||
encryption: boolean,
|
||||
erasureCodeLevel: RedundancyLevel,
|
||||
beeApi: Bee | null,
|
||||
): Promise<BZZ | undefined> => {
|
||||
try {
|
||||
if (Size.fromBytes(capacity).toGigabytes() >= 0 && validityEndDate.getTime() >= new Date().getTime()) {
|
||||
const cost = await beeApi?.getStorageCost(
|
||||
Size.fromBytes(capacity),
|
||||
Duration.fromEndDate(validityEndDate),
|
||||
undefined,
|
||||
encryption,
|
||||
erasureCodeLevel,
|
||||
)
|
||||
|
||||
return cost
|
||||
}
|
||||
|
||||
return undefined
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const fmFetchCost = async (
|
||||
capacity: number,
|
||||
validityEndDate: Date,
|
||||
encryption: boolean,
|
||||
erasureCodeLevel: RedundancyLevel,
|
||||
beeApi: Bee | null,
|
||||
setCost: (cost: BZZ) => void,
|
||||
currentFetch: React.MutableRefObject<Promise<void> | null>,
|
||||
) => {
|
||||
if (currentFetch.current) {
|
||||
await currentFetch.current
|
||||
}
|
||||
|
||||
let isCurrentFetch = true
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const cost = await fmGetStorageCost(capacity, validityEndDate, encryption, erasureCodeLevel, beeApi)
|
||||
|
||||
if (isCurrentFetch) {
|
||||
setCost(cost ?? BZZ.fromDecimalString('0'))
|
||||
}
|
||||
})()
|
||||
|
||||
currentFetch.current = fetchPromise
|
||||
await fetchPromise
|
||||
|
||||
isCurrentFetch = false
|
||||
currentFetch.current = null
|
||||
}
|
||||
|
||||
export const handleCreateDrive = async (
|
||||
beeApi: Bee | null,
|
||||
fm: FileManagerBase | null,
|
||||
size: Size,
|
||||
duration: Duration,
|
||||
label: string,
|
||||
encryption: boolean,
|
||||
erasureCodeLevel: RedundancyLevel,
|
||||
isAdmin: boolean,
|
||||
resetState: boolean,
|
||||
existingBatch: PostageBatch | null,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!beeApi || !fm) return
|
||||
|
||||
try {
|
||||
let batchId: BatchId
|
||||
|
||||
if (!existingBatch) {
|
||||
batchId = await beeApi.buyStorage(size, duration, { label }, undefined, encryption, erasureCodeLevel)
|
||||
} else {
|
||||
batchId = existingBatch.batchID
|
||||
}
|
||||
|
||||
await fm.createDrive(batchId, label, isAdmin, erasureCodeLevel, resetState)
|
||||
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error creating drive:', e instanceof Error ? e.message : String(e))
|
||||
onError?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
interface StampCapacityMetrics {
|
||||
capacityPct: number
|
||||
usedSize: string
|
||||
totalSize: string
|
||||
usedBytes: number
|
||||
totalBytes: number
|
||||
remainingBytes: number
|
||||
}
|
||||
|
||||
export const calculateStampCapacityMetrics = (
|
||||
stamp: PostageBatch | null,
|
||||
drive?: DriveInfo | null,
|
||||
): StampCapacityMetrics => {
|
||||
if (!stamp) {
|
||||
return {
|
||||
capacityPct: 0,
|
||||
usedSize: '—',
|
||||
totalSize: '—',
|
||||
usedBytes: 0,
|
||||
totalBytes: 0,
|
||||
remainingBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let usedBytes = 0
|
||||
let totalBytes = 0
|
||||
let capacityPct = 0
|
||||
let remainingBytes = 0
|
||||
|
||||
if (drive) {
|
||||
totalBytes = stamp.calculateSize(false, drive.redundancyLevel).toBytes()
|
||||
remainingBytes = stamp.calculateRemainingSize(false, drive.redundancyLevel).toBytes()
|
||||
usedBytes = totalBytes - remainingBytes
|
||||
capacityPct = ((totalBytes - remainingBytes) / totalBytes) * 100
|
||||
} else {
|
||||
capacityPct = stamp.usage * 100
|
||||
usedBytes = stamp.size.toBytes() - stamp.remainingSize.toBytes()
|
||||
totalBytes = stamp.size.toBytes()
|
||||
remainingBytes = totalBytes - usedBytes
|
||||
}
|
||||
|
||||
const usedSize = getHumanReadableFileSize(usedBytes)
|
||||
const totalSize = getHumanReadableFileSize(totalBytes)
|
||||
|
||||
return {
|
||||
capacityPct,
|
||||
usedSize,
|
||||
totalSize,
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
remainingBytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const handleDestroyDrive = async (
|
||||
beeApi: Bee | null,
|
||||
fm: FileManagerBase | null,
|
||||
drive: DriveInfo,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!beeApi || !fm) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stamp = (await getUsableStamps(beeApi)).find(s => s.batchID.toString() === drive.batchId.toString())
|
||||
|
||||
if (!stamp) {
|
||||
throw new Error(`Postage stamp (${drive.batchId}) for the current drive (${drive.name}) not found`)
|
||||
}
|
||||
|
||||
const ttlDays = stamp.duration.toDays()
|
||||
|
||||
if (ttlDays <= 2) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Stamp TTL ${ttlDays} <= 2 days, skipping drive destruction: forgetting the drive.`)
|
||||
await fm.forgetDrive(drive)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await fm.destroyDrive(drive, stamp)
|
||||
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const handleForgetDrive = async (
|
||||
fm: FileManagerBase | null,
|
||||
drive: DriveInfo,
|
||||
onSuccess?: () => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): Promise<void> => {
|
||||
if (!fm) return
|
||||
|
||||
try {
|
||||
await fm.forgetDrive(drive)
|
||||
onSuccess?.()
|
||||
} catch (e) {
|
||||
onError?.(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { PrivateKey } from '@ethersphere/bee-js'
|
||||
import { FileInfo, FileStatus } from '@solarpunkltd/file-manager-lib'
|
||||
import { keccak256 } from '@ethersproject/keccak256'
|
||||
import { toUtf8Bytes } from '@ethersproject/strings'
|
||||
|
||||
export function getDaysLeft(expiryDate: Date): number {
|
||||
const now = new Date()
|
||||
|
||||
const diffMs = expiryDate.getTime() - now.getTime()
|
||||
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)))
|
||||
}
|
||||
|
||||
export const fromBytesConversion = (size: number, metric: string) => {
|
||||
switch (metric) {
|
||||
case 'GB':
|
||||
return size / 1000 / 1000 / 1000
|
||||
case 'MB':
|
||||
return size / 1000 / 1000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const lifetimeAdjustments = new Map<number, (date: Date) => void>([
|
||||
[0, date => date.setDate(date.getDate() + 7)],
|
||||
[1, date => date.setMonth(date.getMonth() + 1)],
|
||||
[2, date => date.setMonth(date.getMonth() + 3)],
|
||||
[3, date => date.setMonth(date.getMonth() + 6)],
|
||||
[4, date => date.setFullYear(date.getFullYear() + 1)],
|
||||
])
|
||||
|
||||
export function getExpiryDateByLifetime(lifetimeValue: number, actualValidity?: Date): Date {
|
||||
const now = actualValidity || new Date()
|
||||
|
||||
const adjustDate = lifetimeAdjustments.get(lifetimeValue)
|
||||
|
||||
if (adjustDate) {
|
||||
adjustDate(now)
|
||||
}
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
export const indexStrToBigint = (indexStr?: string): bigint | undefined => {
|
||||
if (!indexStr) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isHex = /[a-fA-F]/.test(indexStr) || indexStr.startsWith('0') || indexStr.length > 10
|
||||
|
||||
if (isHex) {
|
||||
return BigInt(parseInt(indexStr, 16))
|
||||
}
|
||||
|
||||
return BigInt(parseInt(indexStr, 10))
|
||||
}
|
||||
|
||||
export const formatBytes = (v?: string | number): string | undefined => {
|
||||
let n: number
|
||||
|
||||
if (typeof v === 'string') n = Number(v)
|
||||
else if (typeof v === 'number') n = v
|
||||
else n = NaN
|
||||
|
||||
if (!Number.isFinite(n) || n < 0) return undefined
|
||||
|
||||
if (n < 1024) return `${n} B`
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB'] as const
|
||||
let val = n / 1024
|
||||
let i = 0
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024
|
||||
i++
|
||||
}
|
||||
|
||||
return `${val.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
export const isTrashed = (fi: FileInfo): boolean => fi.status === FileStatus.Trashed
|
||||
|
||||
export type Point = { x: number; y: number }
|
||||
export enum Dir {
|
||||
Down = 'down',
|
||||
Up = 'up',
|
||||
}
|
||||
|
||||
export function getFileId(fi: FileInfo): string {
|
||||
return fi.topic.toString()
|
||||
}
|
||||
|
||||
export const KEY_STORAGE = 'privateKey'
|
||||
|
||||
export function getSigner(input: string): PrivateKey {
|
||||
const normalized = input.trim().toLowerCase()
|
||||
const hash = keccak256(toUtf8Bytes(normalized))
|
||||
const privateKeyHex = hash.slice(2)
|
||||
|
||||
return new PrivateKey(privateKeyHex)
|
||||
}
|
||||
|
||||
export function getSignerPk(): PrivateKey | undefined {
|
||||
try {
|
||||
const fromLocalPk = localStorage.getItem(KEY_STORAGE)
|
||||
|
||||
if (!fromLocalPk) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Private key not found, cannot initialize')
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new PrivateKey(fromLocalPk)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Private key error in localStorage under key "${KEY_STORAGE}": `, err)
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function setSignerPk(pk: string): void {
|
||||
localStorage.setItem(KEY_STORAGE, pk)
|
||||
}
|
||||
|
||||
export function removeSignerPk(): void {
|
||||
localStorage.removeItem(KEY_STORAGE)
|
||||
}
|
||||
|
||||
export const capitalizeFirstLetter = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1)
|
||||
|
||||
export const safeSetState =
|
||||
<T>(ref: React.MutableRefObject<boolean>, setter: React.Dispatch<React.SetStateAction<T>>) =>
|
||||
(value: React.SetStateAction<T>) => {
|
||||
if (ref.current) setter(value)
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib'
|
||||
import { getExtensionFromName, guessMime, VIEWERS } from './view'
|
||||
import { AbortManager } from './abortManager'
|
||||
import { DownloadProgress, DownloadState } from '../constants/transfers'
|
||||
|
||||
const downloadAborts = new AbortManager()
|
||||
|
||||
enum Errors {
|
||||
AbortError = 'AbortError',
|
||||
NotAllowedError = 'NotAllowedError',
|
||||
SecurityError = 'SecurityError',
|
||||
}
|
||||
|
||||
export function createDownloadAbort(name: string): void {
|
||||
downloadAborts.create(name)
|
||||
}
|
||||
|
||||
export function abortDownload(name: string): void {
|
||||
downloadAborts.abort(name)
|
||||
}
|
||||
|
||||
const processStream = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
fileHandle: FileSystemFileHandle,
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
const reader = stream.getReader()
|
||||
let writable: WritableStreamDefaultWriter<Uint8Array> | undefined
|
||||
let progress = 0
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
writable = (await (fileHandle as any).createWritable()) as WritableStreamDefaultWriter<Uint8Array>
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
if (signal?.aborted) throw new DOMException('Aborted', Errors.AbortError)
|
||||
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
|
||||
if (value) {
|
||||
await writable.write(value)
|
||||
progress += value.length
|
||||
}
|
||||
done = streamDone
|
||||
|
||||
onDownloadProgress?.({ progress, isDownloading: !done })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if ((e as { name?: string }).name === Errors.AbortError) {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to process stream: ', e)
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
|
||||
try {
|
||||
if (signal?.aborted) {
|
||||
await writable?.abort()
|
||||
} else {
|
||||
await writable?.close()
|
||||
}
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streamToBlob = async (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
mimeType: string,
|
||||
onDownloadProgress?: (dp: DownloadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Blob | undefined> => {
|
||||
const reader = stream.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let progress = 0
|
||||
|
||||
try {
|
||||
let done = false
|
||||
|
||||
while (!done) {
|
||||
if (signal?.aborted) throw new DOMException('Aborted', Errors.AbortError)
|
||||
|
||||
const { value, done: streamDone } = await reader.read()
|
||||
|
||||
if (value) {
|
||||
chunks.push(value)
|
||||
progress += value.length
|
||||
}
|
||||
done = streamDone
|
||||
onDownloadProgress?.({ progress, isDownloading: !done })
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as { name?: string }).name === Errors.AbortError) {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
|
||||
} else {
|
||||
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during stream processing: ', error)
|
||||
}
|
||||
|
||||
return
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
|
||||
let offset = 0
|
||||
for (const c of chunks) {
|
||||
combined.set(c, offset)
|
||||
offset += c.length
|
||||
}
|
||||
|
||||
return new Blob([combined], { type: mimeType })
|
||||
}
|
||||
|
||||
interface FileInfoWithHandle {
|
||||
info: FileInfo
|
||||
handle?: FileSystemFileHandle
|
||||
cancelled?: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isPickerSupported = (): boolean => typeof (window as any).showSaveFilePicker === 'function'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isDirectoryPickerSupported = (): boolean => typeof (window as any).showDirectoryPicker === 'function'
|
||||
|
||||
const isUserCancellation = (error: unknown): boolean => {
|
||||
const errName = (error as { name?: string })?.name
|
||||
|
||||
return errName === Errors.AbortError || errName === Errors.NotAllowedError || errName === Errors.SecurityError
|
||||
}
|
||||
|
||||
const getSingleFileHandle = async (
|
||||
info: FileInfo,
|
||||
defaultDownloadFolder: string,
|
||||
): Promise<FileInfoWithHandle[] | undefined> => {
|
||||
const mimeType = guessMime(info.name, info.customMetadata)
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handle = (await (window as any).showSaveFilePicker({
|
||||
suggestedName: info.name,
|
||||
startIn: defaultDownloadFolder,
|
||||
types: [{ accept: { [mimeType]: [`.${getExtensionFromName(info.name)}`] } }],
|
||||
})) as FileSystemFileHandle
|
||||
|
||||
return [{ info, handle }]
|
||||
} catch (error: unknown) {
|
||||
return isUserCancellation(error) ? [{ info, cancelled: true }] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const getMultipleFileHandles = async (
|
||||
infoList: FileInfo[],
|
||||
defaultDownloadFolder: string,
|
||||
): Promise<FileInfoWithHandle[] | undefined> => {
|
||||
if (!isDirectoryPickerSupported()) {
|
||||
const handles: FileInfoWithHandle[] = []
|
||||
|
||||
for (const info of infoList) {
|
||||
const result = await getSingleFileHandle(info, defaultDownloadFolder)
|
||||
|
||||
if (!result) return undefined
|
||||
handles.push(result[0])
|
||||
}
|
||||
|
||||
return handles
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dirHandle = (await (window as any).showDirectoryPicker({
|
||||
mode: 'readwrite',
|
||||
startIn: defaultDownloadFolder,
|
||||
})) as FileSystemDirectoryHandle
|
||||
|
||||
const handles: FileInfoWithHandle[] = []
|
||||
|
||||
for (const info of infoList) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fileHandle = (await (dirHandle as any).getFileHandle(info.name, {
|
||||
create: true,
|
||||
})) as FileSystemFileHandle
|
||||
|
||||
handles.push({ info, handle: fileHandle })
|
||||
} catch (error: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`Failed to create file handle for ${info.name}:`, error)
|
||||
handles.push({ info, cancelled: true })
|
||||
}
|
||||
}
|
||||
|
||||
return handles
|
||||
} catch (error: unknown) {
|
||||
return isUserCancellation(error) ? infoList.map(info => ({ info, cancelled: true })) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const getFileHandles = (infoList: FileInfo[]): Promise<FileInfoWithHandle[] | undefined> => {
|
||||
const defaultDownloadFolder = 'downloads'
|
||||
|
||||
if (!isPickerSupported()) return Promise.resolve(infoList.map(info => ({ info })))
|
||||
|
||||
if (infoList.length === 1) {
|
||||
return getSingleFileHandle(infoList[0], defaultDownloadFolder)
|
||||
}
|
||||
|
||||
return getMultipleFileHandles(infoList, defaultDownloadFolder)
|
||||
}
|
||||
|
||||
const downloadToDisk = async (
|
||||
streams: ReadableStream<Uint8Array>[],
|
||||
handle: FileSystemFileHandle,
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
for (const stream of streams) {
|
||||
await processStream(stream, handle, onDownloadProgress, signal)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as { name?: string }).name !== Errors.AbortError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during download to disk: ', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadToBlob = async (
|
||||
streams: ReadableStream<Uint8Array>[],
|
||||
info: FileInfo,
|
||||
onDownloadProgress?: (progress: DownloadProgress) => void,
|
||||
isOpenWindow?: boolean,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
for (const stream of streams) {
|
||||
const mime = guessMime(info.name, info.customMetadata)
|
||||
const blob = await streamToBlob(stream, mime, onDownloadProgress, signal)
|
||||
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
let opened = false
|
||||
|
||||
if (isOpenWindow) {
|
||||
opened = openNewWindow(info.name, mime, url)
|
||||
}
|
||||
|
||||
if (!opened) {
|
||||
downloadFromUrl(url, info.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if ((error as { name?: string }).name !== Errors.AbortError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Error during download and open: ', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openNewWindow = (name: string, mime: string, url: string): boolean => {
|
||||
const viewer = VIEWERS.find(v => v.test(mime))
|
||||
const win = window.open('', '_blank')
|
||||
|
||||
if (viewer && win) {
|
||||
viewer.render(win, url, mime, name)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
win?.close()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const downloadFromUrl = (url: string, fileName: string): void => {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
export const startDownloadingQueue = async (
|
||||
fm: FileManager,
|
||||
infoList: FileInfo[],
|
||||
trackers?: Array<(progress: DownloadProgress) => void>,
|
||||
isOpenWindow?: boolean,
|
||||
): Promise<void> => {
|
||||
if (!infoList.length || (trackers && trackers.length !== infoList.length)) return
|
||||
|
||||
try {
|
||||
const fileHandles: FileInfoWithHandle[] | undefined = isOpenWindow
|
||||
? infoList.map(info => ({ info }))
|
||||
: await getFileHandles(infoList)
|
||||
|
||||
if (!fileHandles) return
|
||||
|
||||
await Promise.all(
|
||||
fileHandles.map(async (fh, i) => {
|
||||
const name = fh.info.name
|
||||
const tracker = trackers ? trackers[i] : undefined
|
||||
|
||||
createDownloadAbort(name)
|
||||
const signal = downloadAborts.getSignal(name)
|
||||
|
||||
try {
|
||||
if (fh.cancelled) {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
|
||||
} else {
|
||||
const dataStreams = (await fm.download(fh.info)) as ReadableStream<Uint8Array>[]
|
||||
|
||||
if (isOpenWindow || !fh.handle) {
|
||||
await downloadToBlob(dataStreams, fh.info, tracker, isOpenWindow, signal)
|
||||
} else {
|
||||
await downloadToDisk(dataStreams, fh.handle, tracker, signal)
|
||||
}
|
||||
|
||||
// Ensure the tracker shows completion
|
||||
if (tracker) {
|
||||
const size = fh.info.customMetadata?.size
|
||||
const finalProgress = size ? Number(size) : 0
|
||||
|
||||
tracker({ progress: finalProgress, isDownloading: false })
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const isAbortError = (error as { name?: string }).name === Errors.AbortError
|
||||
|
||||
// Ensure the tracker shows completion
|
||||
if (!isAbortError) {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Error })
|
||||
} else {
|
||||
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
|
||||
}
|
||||
} finally {
|
||||
downloadAborts.abort(name)
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
// Errors are handled per-file in the map above
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { FileStatus, FileInfo, FileManagerBase } from '@solarpunkltd/file-manager-lib'
|
||||
import { GetGranteesResult, PostageBatch } from '@ethersphere/bee-js'
|
||||
|
||||
import GeneralIcon from 'remixicon-react/FileTextLineIcon'
|
||||
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||
import AccessIcon from 'remixicon-react/ShieldKeyholeLineIcon'
|
||||
import HardDriveIcon from 'remixicon-react/HardDrive2LineIcon'
|
||||
import { indexStrToBigint } from './common'
|
||||
import { FEED_INDEX_ZERO, erasureCodeMarks } from '../constants/common'
|
||||
|
||||
export type FileProperty = { key: string; label: string; value: string; raw?: string }
|
||||
export type FilePropertyGroup = { title: string; icon?: ReactElement; properties: FileProperty[] }
|
||||
|
||||
type KnownCustomMeta = Record<string, string> & {
|
||||
size?: string
|
||||
mime?: string
|
||||
path?: string
|
||||
fileCount?: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
const dash = '—'
|
||||
|
||||
const formatBytes = (bytes?: number | string) => {
|
||||
const n = typeof bytes === 'string' ? Number(bytes) : bytes
|
||||
|
||||
if (!Number.isFinite(n as number) || (n as number) < 0) return dash
|
||||
|
||||
if ((n as number) < 1024) return `${n} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let v = (n as number) / 1024
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const truncateMiddle = (s?: string, start = 8, end = 8) => {
|
||||
if (!s) return dash
|
||||
|
||||
return s.length <= start + end + 3 ? s : `${s.slice(0, start)}...${s.slice(-end)}`
|
||||
}
|
||||
|
||||
const fmtDate = (ts?: number) => {
|
||||
if (ts === undefined || !Number.isFinite(ts)) return dash
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return dash
|
||||
}
|
||||
}
|
||||
|
||||
async function getCreatedTs(fm: FileManagerBase, fi: FileInfo): Promise<number | undefined> {
|
||||
try {
|
||||
const v0 = await fm.getVersion(fi, FEED_INDEX_ZERO.toString())
|
||||
|
||||
return v0.timestamp
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function extractGranteeCount(r: GetGranteesResult): number {
|
||||
const obj = r as unknown as Record<string, unknown>
|
||||
const pk = obj.publicKeys
|
||||
|
||||
if (Array.isArray(pk)) return pk.length
|
||||
const gs = obj.grantees
|
||||
|
||||
if (Array.isArray(gs)) return gs.length
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export async function getGranteeCount(fm: FileManagerBase, fi: FileInfo): Promise<number | undefined> {
|
||||
try {
|
||||
const result = await fm.getGrantees(fi)
|
||||
|
||||
return extractGranteeCount(result)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildGeneralGroup(
|
||||
fi: FileInfo,
|
||||
mime?: string,
|
||||
size?: number | string,
|
||||
path?: string,
|
||||
fileCount?: string,
|
||||
): FilePropertyGroup {
|
||||
return {
|
||||
title: 'General',
|
||||
icon: <GeneralIcon size="14px" color="rgb(237, 129, 49)" />,
|
||||
properties: [
|
||||
{ key: 'type', label: 'Type', value: mime ?? dash },
|
||||
{ key: 'size', label: 'Size', value: size != null ? formatBytes(size) : dash },
|
||||
{ key: 'count', label: 'Items', value: fileCount ?? '1' },
|
||||
{ key: 'path', label: 'Location', value: path || dash },
|
||||
{
|
||||
key: 'hash',
|
||||
label: 'Swarm hash',
|
||||
value: truncateMiddle(fi.file.reference.toString()),
|
||||
raw: fi.file.reference.toString(),
|
||||
},
|
||||
{ key: 'ver', label: 'Versions', value: ((indexStrToBigint(fi.version) ?? BigInt(0)) + BigInt(1)).toString() },
|
||||
{ key: 'status', label: 'Status', value: !fi.status ? FileStatus.Active : fi.status },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildDatesGroup(createdTs?: number, modifiedTs?: number, expires?: string): FilePropertyGroup {
|
||||
return {
|
||||
title: 'Dates',
|
||||
icon: <CalendarIcon size="14px" color="rgb(237, 129, 49)" />,
|
||||
properties: [
|
||||
{ key: 'created', label: 'Created', value: fmtDate(createdTs) },
|
||||
{ key: 'modified', label: 'Modified', value: fmtDate(modifiedTs) },
|
||||
{ key: 'expires', label: 'Expires', value: expires ?? dash },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildAccessGroup(fi: FileInfo, granteeCount?: number): FilePropertyGroup {
|
||||
return {
|
||||
title: 'Access & Permissions',
|
||||
icon: <AccessIcon size="14px" color="rgb(237, 129, 49)" />,
|
||||
properties: [
|
||||
{
|
||||
key: 'owner',
|
||||
label: 'Owner',
|
||||
value: truncateMiddle(fi.owner.toString()),
|
||||
raw: fi.owner.toString(),
|
||||
},
|
||||
{ key: 'shared', label: 'Sharing', value: fi.shared ? 'Shared' : 'Private' },
|
||||
{ key: 'grantees', label: 'Grantees', value: granteeCount != null ? `${granteeCount}` : dash },
|
||||
{
|
||||
key: 'actpub',
|
||||
label: 'ACT Publisher',
|
||||
value: truncateMiddle(fi.actPublisher.toString()),
|
||||
raw: fi.actPublisher.toString(),
|
||||
},
|
||||
{
|
||||
key: 'topic',
|
||||
label: 'Topic',
|
||||
value: truncateMiddle(fi.topic.toString()),
|
||||
raw: fi.topic.toString(),
|
||||
},
|
||||
{
|
||||
key: 'historyRef',
|
||||
label: 'ACT History',
|
||||
value: truncateMiddle(fi.file.historyRef.toString()),
|
||||
raw: fi.file.historyRef.toString(),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function buildStorageGroup(fi: FileInfo, driveName: string, stamp?: PostageBatch): FilePropertyGroup {
|
||||
const stampValue = stamp
|
||||
? stamp.label + ' (' + truncateMiddle(fi.batchId.toString(), 4, 4) + ')'
|
||||
: truncateMiddle(fi.batchId.toString())
|
||||
|
||||
const redundancyLabel =
|
||||
fi.redundancyLevel !== undefined
|
||||
? erasureCodeMarks.find(mark => mark.value === fi.redundancyLevel)?.label ?? fi.redundancyLevel.toString()
|
||||
: dash
|
||||
|
||||
return {
|
||||
title: 'Storage',
|
||||
icon: <HardDriveIcon size="14px" color="rgb(237, 129, 49)" />,
|
||||
properties: [
|
||||
{
|
||||
key: 'batch',
|
||||
label: 'Batch ID',
|
||||
value: stampValue,
|
||||
raw: fi.batchId.toString(),
|
||||
},
|
||||
{ key: 'drive', label: 'Drive', value: driveName },
|
||||
{ key: 'redundancy', label: 'Redundancy', value: redundancyLabel },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildGetInfoGroups(
|
||||
fm: FileManagerBase,
|
||||
fi: FileInfo,
|
||||
driveName: string,
|
||||
stamp?: PostageBatch,
|
||||
): Promise<FilePropertyGroup[]> {
|
||||
const cm = fi.customMetadata as KnownCustomMeta | undefined
|
||||
const size = cm?.size
|
||||
const mime = cm?.mime
|
||||
const path = cm?.path || driveName // TODO: set exact subpath
|
||||
const fileCount = cm?.fileCount
|
||||
const expires = cm?.expiresAt || stamp?.duration.toEndDate().toLocaleDateString()
|
||||
|
||||
const [createdTs, granteeCount] = await Promise.all([getCreatedTs(fm, fi), getGranteeCount(fm, fi)])
|
||||
|
||||
return [
|
||||
buildGeneralGroup(fi, mime, size, path, fileCount),
|
||||
buildDatesGroup(createdTs, fi.timestamp, expires),
|
||||
buildAccessGroup(fi, granteeCount),
|
||||
buildStorageGroup(fi, driveName, stamp),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Point, Dir } from './common'
|
||||
|
||||
export function computeContextMenuPosition(args: {
|
||||
clickPos: Point
|
||||
menuRect: DOMRect
|
||||
viewport: { w: number; h: number }
|
||||
margin?: number
|
||||
containerRect?: DOMRect | null
|
||||
}): { safePos: Point; dropDir: Dir } {
|
||||
const { clickPos: pos, menuRect: rect, viewport } = args
|
||||
const margin = args.margin ?? 8
|
||||
const left = Math.max(margin, Math.min(pos.x, viewport.w - rect.width - margin))
|
||||
const vh = viewport.h
|
||||
let top = pos.y
|
||||
let dir: Dir = Dir.Down
|
||||
|
||||
const spaceBelow = vh - pos.y
|
||||
|
||||
if (spaceBelow < rect.height * 1.4) {
|
||||
top = Math.max(margin, pos.y - rect.height - margin)
|
||||
dir = Dir.Up
|
||||
} else {
|
||||
top = Math.max(margin, Math.min(pos.y, vh - rect.height - margin))
|
||||
}
|
||||
|
||||
return { safePos: { x: left, y: top }, dropDir: dir }
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
mp4: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
ogv: 'video/ogg',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
avif: 'image/avif',
|
||||
svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
}
|
||||
|
||||
export function getExtensionFromName(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
export function guessMime(name: string, mtdt?: Record<string, string> | undefined): string {
|
||||
const md = mtdt?.mimeType || mtdt?.mime || mtdt?.['content-type']
|
||||
|
||||
if (md) return md
|
||||
|
||||
const ext = getExtensionFromName(name)
|
||||
|
||||
return EXT_TO_MIME[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
export type Viewer = {
|
||||
name: string
|
||||
test: (mime: string) => boolean
|
||||
render: (win: Window, url: string, mime: string, name: string) => void
|
||||
}
|
||||
|
||||
const VIDEO_HTML = (u: string, title: string) =>
|
||||
`<html><head><meta charset="utf-8"/><title>${title}</title></head><body style="margin:0;background:#000">
|
||||
<video controls autoplay style="width:100%;height:100%" src="${u}"></video>
|
||||
</body></html>`
|
||||
|
||||
const AUDIO_HTML = (u: string, title: string) =>
|
||||
`<html><head><meta charset="utf-8"/><title>${title}</title></head><body>
|
||||
<audio controls autoplay style="width:100%" src="${u}"></audio>
|
||||
</body></html>`
|
||||
|
||||
const IMAGE_HTML = (u: string, title: string) =>
|
||||
`<html><head><meta charset="utf-8"/><title>${title}</title></head><body style="margin:0;background:#111;display:grid;place-items:center;min-height:100vh">
|
||||
<img style="max-width:100%;max-height:100vh" src="${u}" />
|
||||
</body></html>`
|
||||
|
||||
export const VIEWERS: Viewer[] = [
|
||||
{
|
||||
name: 'video',
|
||||
test: m => m.startsWith('video/'),
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.write(VIDEO_HTML(url, name))
|
||||
w.document.title = name
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'audio',
|
||||
test: m => m.startsWith('audio/'),
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.write(AUDIO_HTML(url, name))
|
||||
w.document.title = name
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
test: m => m.startsWith('image/'),
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.write(IMAGE_HTML(url, name))
|
||||
w.document.title = name
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pdf',
|
||||
test: m => m === 'application/pdf',
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.title = name
|
||||
w.location.href = url
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'html',
|
||||
test: m => m === 'text/html',
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.title = name
|
||||
w.location.href = url
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'text-like',
|
||||
test: m => m.startsWith('text/') || m === 'application/json' || m === 'text/markdown',
|
||||
render: (w, url, mime, name) => {
|
||||
w.document.title = name
|
||||
w.location.href = url
|
||||
},
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user