Seed Protocol
@seedprotocol/react

Files and OPFS

Hooks and a ready-made component for listing, browsing and managing the files a Seed app stores in the browser's private file system (OPFS).

In the browser, the SDK keeps its SQLite database and its files (uploaded File and Image values and downloads from Arweave) in the browser's private file system (OPFS), under your config's filesDir (filesDir and endpoints says how it resolves). With filesDir: '.seed', the database is .seed/db/seed.db, and files go in subfolders such as .seed/files/, .seed/images/ and .seed/html/.

useFiles and useImageFiles list one folder of that storage through the Seed client and refresh when the SDK saves files there. useOPFSFiles and OPFSFilesManager read OPFS directly, without the Seed client; use them for debugging and for letting people see and clear what the app stores.

import {
  useFiles,
  FILES_QUERY_KEY_PREFIX,
  useImageFiles,
  useOPFSFiles,
  OPFSFilesManager,
  OPFS_FILES_MANAGER_CSS,
  useStorageEstimate,
  useThumbnail,
} from '@seedprotocol/react'
import type { OPFSFile, OPFSFilesManagerProps, FileEntry } from '@seedprotocol/react'

useFiles

Lists the filenames in one folder of the SDK's file storage.

function useFiles(dir?: string): {
  files: string[]
  isLoading: boolean
  error: Error | null
  refetch: () => Promise<unknown>
}

Parameters

NameTypeDefaultDescription
dirstring'files'A folder under the SDK's storage root, for example 'files' or 'images'.

Returns

FieldTypeDescription
filesstring[]Filenames (not paths) of the files directly in dir. Subfolders aren't listed or scanned. [] when the folder doesn't exist or the client isn't ready.
isLoadingbooleantrue during the first fetch.
errorError | nullThe fetch error, if any.
refetchfunctionReact Query's refetch.

It waits for the client to be ready. Outside SeedProvider it uses a shared fallback query client and development builds log a warning once, so keep it inside the provider. The list refetches when the SDK saves a file into dir, when the SDK's download and resize workers write files there, and when another tab writes files into dir. Writes that bypass the SDK aren't noticed; call refetch after them.

The list gives filenames only. To show a File property's stored file, use the property's value: a blob: URL for the file, after a save and after a page reload. The SDK revokes that URL when the value changes to another file and when the property stops, so read the value again after a change rather than keeping the URL. See File, Image and Html properties.

import { useFiles } from '@seedprotocol/react'

function StoredFiles() {
  const { files, isLoading, error, refetch } = useFiles('files')

  if (isLoading) return <p>Loading…</p>
  if (error) return <p>{error.message}</p>

  return (
    <>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {files.map((name) => (
          <li key={name}>{name}</li>
        ))}
      </ul>
    </>
  )
}

FILES_QUERY_KEY_PREFIX

The start of useFiles' React Query keys: ['seed', 'files']. Each folder's key is [...FILES_QUERY_KEY_PREFIX, dir]. Invalidate the prefix to refresh every useFiles list:

queryClient.invalidateQueries({ queryKey: FILES_QUERY_KEY_PREFIX })

useImageFiles

Lists the original images in the SDK's images/ folder. The resized copies in images/<width>/ aren't listed.

function useImageFiles(): {
  imageFiles: string[]
  isLoading: boolean
  error: Error | null
  refetch: () => Promise<unknown>
}

It returns the same list as useFiles('images'), refreshes on the same events, and behaves the same outside SeedProvider; imageFiles is [] until the client is ready. Its query key is ['seed', 'imageFiles'], which isn't exported and isn't under FILES_QUERY_KEY_PREFIX, so invalidating that prefix doesn't refresh it; call refetch instead.

import { useImageFiles } from '@seedprotocol/react'

function ImageCount() {
  const { imageFiles, isLoading } = useImageFiles()
  return <p>{isLoading ? '…' : `${imageFiles.length} image(s) stored`}</p>
}

useOPFSFiles

Scans OPFS recursively and lists every file with its size and type. It uses the browser API directly and doesn't need the Seed client or SeedProvider.

function useOPFSFiles(options?: { rootPath?: string }): {
  files: OPFSFile[]
  isLoading: boolean
  hasLoaded: boolean
  error: string | null
  errorName: string | null
  refetch: () => Promise<void>
}

interface OPFSFile {
  name: string
  path: string
  size: number
  type: string
  detectedType?: string
  lastModified: number
}

OPFSFile and the options type UseOPFSFilesOptions are exported.

Parameters

NameTypeDefaultDescription
rootPathstringthe OPFS rootA folder to scan instead, relative to the OPFS root, for example '.seed'.

Returns

FieldTypeDescription
filesOPFSFile[]Every file under the root, sorted by path.
isLoadingbooleantrue during every scan, including refetches. files keeps the previous result until a scan succeeds.
hasLoadedbooleanfalse until the first scan finishes.
errorstring | nullA message such as 'Failed to access OPFS: …'. A string, not an Error.
errorNamestring | nullThe thrown error's name, for example 'NotFoundError' when rootPath doesn't exist.
refetch() => Promise<void>Scan again.

OPFSFile fields:

FieldDescription
nameThe filename.
pathThe path from the OPFS root, for example '.seed/images/photo.jpg'.
sizeSize in bytes.
typeThe MIME type the browser infers from the extension, or 'application/octet-stream'.
detectedTypeA type sniffed from the file's first bytes, set only when the browser gave no type, for example for images saved under an Arweave transaction ID with no extension.
lastModifiedLast modified time (ms since epoch).

The hook scans once when it mounts and when rootPath changes. It doesn't watch for new files, so call refetch after writing. Files that can't be read are skipped with a console warning.

import { useOPFSFiles } from '@seedprotocol/react'

function StorageUsage() {
  const { files, hasLoaded, error, refetch } = useOPFSFiles({ rootPath: '.seed' })

  if (error) return <p>{error}</p>
  if (!hasLoaded) return <p>Scanning…</p>

  const total = files.reduce((sum, f) => sum + f.size, 0)
  return (
    <p>
      {files.length} files, {(total / 1024 / 1024).toFixed(1)} MB{' '}
      <button onClick={() => refetch()}>Rescan</button>
    </p>
  )
}

OPFSFilesManager

A file browser for OPFS: a folder tree with grid and list views, previews, multi-select, download (several files as a .zip), copy path, and delete with confirmation. It shows the origin's storage usage, and works without the Seed client.

function OPFSFilesManager(props: OPFSFilesManagerProps): JSX.Element

Props

All props are optional.

NameTypeDefaultDescription
rootPathstringthe OPFS rootFolder to show, relative to the OPFS root, for example '.seed'.
filter(file: OPFSFile) => booleanWhich files to include.
titlestring'Files'Heading text.
descriptionstringa sentence about OPFSText under the heading.
headingLevel1–62Heading level for title.
defaultView'grid' | 'list'by folderStarting view. By default, folders that are mostly images open as a grid and others as a list, until the user picks a view.
groupImageVariantsbooleantrueShow the SDK's resized copies (images/<width>/) together with their original instead of as separate files.
theme'system' | 'light' | 'dark' | 'none''system''system' follows the OS. 'none' doesn't inject the stylesheet and renders unstyled markup with seed-fm-* classes.
classNamestringClass for the root element.
classNamesPartial<Record<OPFSFilesManagerSlot, string>>{}Extra classes per element. Slots: root, header, toolbar, body, folder, grid, tile, list, row, batchBar, panel, dialog, toast.
onDownload(file: OPFSFile, blob: Blob) => void | Promise<void>browser downloadHandle downloads yourself, for example in Electron. For several files, it's called once with a .zip blob and a synthetic file whose type is 'application/zip'.
confirmDelete(files: OPFSFile[]) => Promise<boolean>built-in dialogReplace the confirmation dialog. Receives every file that would be removed, including resized copies. Resolve true to delete.
deleteWarning(files: OPFSFile[]) => string | nullwarns about the Seed databaseExtra warning in the built-in dialog, or null for none. The default warns when the selection includes db/seed.db.
deleteAction(files: OPFSFile[]) => OPFSFilesManagerDeleteActionRelabel delete (label) or make it non-destructive (destructive: false) for a set of files, for example ones that have a copy on Arweave. Runs on every render for every row, so keep it cheap and synchronous.
onBeforeDelete(file: OPFSFile) => boolean | Promise<boolean>Called for each file after the user confirms. Return false to keep that file.
onAfterDelete(paths: string[]) => void | Promise<void>Called with the deleted paths. Use it to update app state.
onNotify(message: string, tone: 'success' | 'error', notice: OPFSFilesManagerNotice) => voidbuilt-in toastsShow results in your own toaster. When set, the built-in toasts aren't shown. notice says what happened (kind: 'delete' | 'download' | 'copy-path') so you can write your own wording.

The prop and helper types are exported: OPFSFilesManagerProps, OPFSFilesManagerTheme, OPFSFilesManagerView, OPFSFilesManagerSlot, OPFSFilesManagerClassNames, OPFSFilesManagerDeleteAction, OPFSFilesManagerNotice and OPFSFilesManagerNotifyTone.

Deleting here removes files from OPFS only. The Seed database still refers to them, and deleting .seed/db/seed.db throws away every local draft that hasn't been published. Use onAfterDelete to clear or reload app state, and keep the default deleteWarning unless you replace it with something as clear.

The file list is scanned when the component mounts and after each delete. It has a refresh button for files written while it's open. In browsers without OPFS (some private windows, older browsers) it shows a message instead.

Example

import { OPFSFilesManager } from '@seedprotocol/react'

function StoragePage() {
  return (
    <OPFSFilesManager
      rootPath=".seed"
      title="Stored files"
      theme="system"
      onBeforeDelete={(file) => !file.path.endsWith('db/seed.db')}
      onAfterDelete={(paths) => {
        console.log('Deleted', paths)
      }}
      onNotify={(message, tone) => {
        if (tone === 'error') console.error(message)
      }}
    />
  )
}

Styling

Unless theme is 'none', the component injects its stylesheet into document.head once, inside the CSS layer seed-opfs-files-manager. Because it's in a layer, your own unlayered styles win over it. Every colour comes from a CSS custom property on .seed-fm, such as --seed-bg, --seed-surface, --seed-fg, --seed-border, --seed-accent and --seed-danger. Override them on .seed-fm or an ancestor to restyle:

.seed-fm {
  --seed-accent: #6d28d9;
  --seed-radius-md: 4px;
}

OPFS_FILES_MANAGER_CSS

The component's stylesheet as a string, wrapped in @layer seed-opfs-files-manager { … }. Use it to include the styles in server-rendered HTML, so the component doesn't render unstyled before its effect runs. Give the element the id seed-opfs-files-manager-styles: the component skips injecting when an element with that id is already present.

import { OPFS_FILES_MANAGER_CSS } from '@seedprotocol/react'

export function FilesManagerStyles() {
  return <style id="seed-opfs-files-manager-styles">{OPFS_FILES_MANAGER_CSS}</style>
}

useStorageEstimate

The storage usage that OPFSFilesManager shows in its header: navigator.storage.estimate() as a hook.

function useStorageEstimate(revision: unknown): StorageEstimate | null

type StorageEstimate = {
  usage: number // bytes this origin uses, across OPFS, IndexedDB, Cache Storage and so on
  quota: number // bytes this origin may use
}

It reads the estimate on mount and again whenever revision changes (compared by identity), so pass something that changes when files do, such as useOPFSFiles().files. It returns null until the first reading, and stays null where navigator.storage.estimate isn't available. Browsers round and pad these numbers. StorageEstimate is exported.

import { useOPFSFiles, useStorageEstimate } from '@seedprotocol/react'

function StorageUsage() {
  const { files } = useOPFSFiles({ rootPath: '.seed' })
  const estimate = useStorageEstimate(files)
  if (!estimate) return null
  const mb = (bytes: number) => (bytes / 1024 / 1024).toFixed(1)
  return <p>{mb(estimate.usage)} MB of {mb(estimate.quota)} MB used</p>
}

useThumbnail

Loads a thumbnail for an image file once its element scrolls near the viewport. OPFSFilesManager uses it for its tiles.

function useThumbnail(entry: FileEntry): {
  ref: (el: Element | null) => void
  url: string | null
  status: ThumbnailStatus // 'idle' | 'loading' | 'ready' | 'error'
  markError: () => void
}

type FileEntry = {
  file: OPFSFile
  variants: { file: OPFSFile; width: number }[] // resized copies, narrowest first
  totalSize: number
}
FieldDescription
refAttach it to the element that shows the thumbnail. Nothing loads until that element is within 300 px of the viewport
urlA blob: URL of the thumbnail once status is 'ready', otherwise null
status'idle' until the element is near the viewport, and always for files that aren't images; then 'loading', 'ready' or 'error'
markErrorSets status to 'error'. Call it from the <img>'s onError

The thumbnail is the first entry of variants when there is one. Otherwise it's the original, downscaled to 400 px wide (as WebP) when it's 256 KB or larger. Thumbnails are shared between components and decoded at most four at a time. The hook owns the blob: URL and revokes it about 30 seconds after the last component using it unmounts, so don't keep url elsewhere. FileEntry and ThumbnailStatus are exported.

import { useThumbnail, type FileEntry, type OPFSFile } from '@seedprotocol/react'

function Thumbnail({ file }: { file: OPFSFile }) {
  const entry: FileEntry = { file, variants: [], totalSize: file.size }
  const { ref, url, status, markError } = useThumbnail(entry)
  return (
    <div ref={ref} style={{ width: 120, height: 120 }}>
      {url && <img src={url} alt={file.name} width={120} onError={markError} />}
      {status === 'error' && <span>No preview</span>}
    </div>
  )
}

On this page