diff --git a/packages/console/e2e/smoke.spec.ts b/packages/console/e2e/smoke.spec.ts
new file mode 100644
index 000000000..1c3a5cc6a
--- /dev/null
+++ b/packages/console/e2e/smoke.spec.ts
@@ -0,0 +1,134 @@
+import { test, expect } from '@playwright/test'
+
+const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3000'
+
+test.describe('Console Smoke Tests', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(CONSOLE_URL)
+ })
+
+ test('should load the console homepage', async ({ page }) => {
+ // Should show authentication form for unauthenticated users
+ await expect(page.locator('.authenticator')).toBeVisible()
+
+ // Should have email input for authentication
+ await expect(page.locator('input[type="email"]')).toBeVisible()
+
+ // Should have authorize button
+ await expect(page.locator('button[type="submit"]')).toBeVisible()
+
+ // Should have proper page title
+ await expect(page).toHaveTitle(/Storacha console/)
+ })
+
+ test('should display terms of service', async ({ page }) => {
+ // Check that terms of service link is present
+ await expect(page.locator('a[href="https://docs.storacha.network/terms/"]')).toBeVisible()
+ })
+
+ test('should have working navigation structure', async ({ page }) => {
+ // Check if navigation elements are present
+ // Note: These tests assume unauthenticated state, so we're testing the basic page structure
+
+ // Authentication form should be present
+ await expect(page.locator('form')).toBeVisible()
+
+ // Should show Storacha logo
+ await expect(page.locator('svg, img')).toBeVisible()
+ })
+
+ test('should handle iframe context detection', async ({ page }) => {
+ // Test iframe page specifically
+ await page.goto(`${CONSOLE_URL}/iframe`)
+
+ // Should still load without errors - iframe may show different title
+ await expect(page).toHaveTitle(/.+/) // Just verify some title exists
+ })
+
+ test('should handle error pages gracefully', async ({ page }) => {
+ // Test non-existent page
+ const response = await page.goto(`${CONSOLE_URL}/non-existent-page`, {
+ waitUntil: 'networkidle'
+ })
+
+ // Should return 404 or redirect gracefully
+ expect([200, 404]).toContain(response?.status() || 0)
+ })
+})
+
+test.describe('Console Authentication Flow', () => {
+ test('should validate email input', async ({ page }) => {
+ await page.goto(CONSOLE_URL)
+
+ const emailInput = page.locator('input[type="email"]')
+ const submitButton = page.locator('button[type="submit"]')
+
+ // Try submitting with invalid email
+ await emailInput.fill('invalid-email')
+ await submitButton.click()
+
+ // Should show browser validation message or prevent submission
+ // Note: This depends on browser behavior for invalid emails
+ await expect(emailInput).toBeVisible()
+ })
+
+ test('should handle authentication form submission', async ({ page }) => {
+ await page.goto(CONSOLE_URL)
+
+ const emailInput = page.locator('input[type="email"]')
+ const submitButton = page.locator('button[type="submit"]')
+
+ // Fill valid email
+ await emailInput.fill('test@example.com')
+ await submitButton.click()
+
+ // Should show submission state or redirect
+ // In real implementation, this would show "check your email" message
+ // For smoke test, we just verify the form interaction works
+ await expect(submitButton).toBeVisible()
+ })
+})
+
+test.describe('Console UI Components', () => {
+ test('should load with proper styling', async ({ page }) => {
+ await page.goto(CONSOLE_URL)
+
+ // Check if Tailwind CSS classes are applied
+ const authenticator = page.locator('.authenticator')
+ await expect(authenticator).toBeVisible()
+
+ // Check for hot-red theme colors (custom Tailwind classes)
+ const submitButton = page.locator('button[type="submit"]')
+ const buttonClasses = await submitButton.getAttribute('class')
+ expect(buttonClasses).toContain('hot-red')
+ })
+
+ test('should be responsive on mobile', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+ await page.goto(CONSOLE_URL)
+
+ // Should still be usable on mobile
+ await expect(page.locator('.authenticator')).toBeVisible()
+ await expect(page.locator('input[type="email"]')).toBeVisible()
+ await expect(page.locator('button[type="submit"]')).toBeVisible()
+ })
+})
+
+test.describe('Console Routing', () => {
+ test('should handle space routes', async ({ page }) => {
+ // Test space creation route
+ const response = await page.goto(`${CONSOLE_URL}/space/create`)
+ expect([200, 302]).toContain(response?.status() || 0) // 302 for redirect to auth
+ })
+
+ test('should handle space import route', async ({ page }) => {
+ const response = await page.goto(`${CONSOLE_URL}/space/import`)
+ expect([200, 302]).toContain(response?.status() || 0)
+ })
+
+ test('should handle settings route', async ({ page }) => {
+ const response = await page.goto(`${CONSOLE_URL}/settings`)
+ expect([200, 302]).toContain(response?.status() || 0)
+ })
+})
\ No newline at end of file
diff --git a/packages/console/package.json b/packages/console/package.json
index 85ad91cb1..4d9c82e0e 100644
--- a/packages/console/package.json
+++ b/packages/console/package.json
@@ -7,7 +7,10 @@
"build": "next build",
"build:fresh": "nx build --skip-nx-cache",
"start": "next start",
- "test": "pnpm lint",
+ "test": "pnpm lint && pnpm test:e2e:headless",
+ "test:e2e": "playwright test",
+ "test:e2e:headless": "playwright test --project=chromium",
+ "test:e2e:ui": "playwright test --ui",
"lint": "next lint",
"pages:build": "pnpm dlx @cloudflare/next-on-pages@1",
"pages:watch": "pnpm @cloudflare/next-on-pages@1 --watch",
@@ -53,6 +56,7 @@
},
"devDependencies": {
"@cloudflare/next-on-pages": "^1.6.3",
+ "@playwright/test": "^1.51.1",
"@types/archy": "^0.0.36",
"@types/blueimp-md5": "^2.18.0",
"@types/node": "^18.19.75",
diff --git a/packages/console/playwright.config.ts b/packages/console/playwright.config.ts
new file mode 100644
index 000000000..3535ed214
--- /dev/null
+++ b/packages/console/playwright.config.ts
@@ -0,0 +1,73 @@
+import { defineConfig, devices } from '@playwright/test';
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+export default defineConfig({
+ testDir: './e2e',
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: 'html',
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: process.env.CONSOLE_URL || 'http://localhost:3000',
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: 'on-first-retry',
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+
+ {
+ name: 'firefox',
+ use: { ...devices['Desktop Firefox'] },
+ },
+
+ {
+ name: 'webkit',
+ use: { ...devices['Desktop Safari'] },
+ },
+
+ /* Test against mobile viewports. */
+ {
+ name: 'Mobile Chrome',
+ use: { ...devices['Pixel 5'] },
+ },
+ {
+ name: 'Mobile Safari',
+ use: { ...devices['iPhone 12'] },
+ },
+
+ /* Test against branded browsers. */
+ // {
+ // name: 'Microsoft Edge',
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
+ // },
+ // {
+ // name: 'Google Chrome',
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
+ // },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: process.env.CI ? undefined : {
+ command: 'pnpm dev',
+ url: 'http://localhost:3000',
+ reuseExistingServer: !process.env.CI,
+ stdout: 'ignore',
+ stderr: 'pipe',
+ },
+});
\ No newline at end of file
diff --git a/packages/console/src/components/SharingManager.tsx b/packages/console/src/components/SharingManager.tsx
new file mode 100644
index 000000000..9629f083e
--- /dev/null
+++ b/packages/console/src/components/SharingManager.tsx
@@ -0,0 +1,252 @@
+'use client'
+import React from 'react'
+import { SharingTools, useSharingTools, SpaceDID } from '@storacha/ui-react'
+import { CloudArrowDownIcon, PaperAirplaneIcon, ArrowDownOnSquareStackIcon, InformationCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'
+import { useW3 } from '@storacha/ui-react'
+import { H2, H3 } from '@/components/Text'
+import CopyButton from './CopyButton'
+import Tooltip from './Tooltip'
+
+interface SharingManagerProps {
+ spaceDID: SpaceDID
+}
+
+function ShareForm() {
+ const [{ shareValue, shareError }] = useSharingTools()
+
+ const isDID = (value: string): boolean => {
+ try {
+ return /^did:[a-z0-9]+:[a-zA-Z0-9._%-]+$/i.test(value.trim())
+ } catch {
+ return false
+ }
+ }
+
+ const isEmail = (value: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return !isDID(value) && emailRegex.test(value)
+ }
+
+ return (
+
+
+ Ask your friend for their Email or Decentralized Identifier (DID) and paste it
+ below:
+
+
+
+
+
+
+ {isEmail(shareValue) ? 'Share via Email' : isDID(shareValue) ? (
+ <>
+
+ {'Download UCAN'}
+ >
+ ) : 'Enter a valid email or DID'}
+
+
+
+ {shareError && (
+
+ )}
+
+ )
+}
+
+function DelegationsList() {
+ const [{ delegations, loadingDelegations }] = useSharingTools()
+
+ // Separate active and revoked delegations
+ const activeDelegations = delegations.filter(item => !item.revoked)
+ const revokedDelegations = delegations.filter(item => item.revoked)
+
+ return (
+ <>
+ {/* Active Delegations Panel */}
+ {(activeDelegations.length > 0 || loadingDelegations) && (
+
+
+ Shared With:
+
+ {loadingDelegations ? (
+
+
+
Checking delegation status...
+
+ ) : (
+
+ {activeDelegations.map((delegation, i) => (
+
+
+
+
{delegation.email}
+
+
+ Capabilities
+ {delegation.capabilities.map((c, j) => (
+ {c}
+ ))}
+
+
+
+
+ {truncateCID(delegation.delegation.cid.toString())}
+
+
+
+
+
+
+
+
+
+
+ Revoke
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Revoked Delegations Panel */}
+ {revokedDelegations.length > 0 && (
+
+
+ Revoked Access:
+
+
+ {revokedDelegations.map((delegation, i) => (
+
+
+
+
{delegation.email}
+
+
+ Capabilities (Revoked)
+ {delegation.capabilities.map((c, j) => (
+ {c}
+ ))}
+
+
+
+
+ {truncateCID(delegation.delegation.cid.toString())}
+
+
+
+
+
+
+
+
+
+
+ Revoked
+
+
+ ))}
+
+
+ )}
+ >
+ )
+}
+
+function ImportSection() {
+ const [{ client }] = useW3()
+ const [{ importedDelegation }] = useSharingTools()
+
+ const body = `Please send me a UCAN delegation to access to your space. My agent DID is:\\n\\n${client?.did()}`
+ .replace(/ /g, '%20')
+ .replace(/\\n/g, '%0A')
+
+ return (
+
+
Import Space Access
+
+ -
+ Send your DID to your friend.
+
+ {client?.did()}
+
+ Copy DID
+
+
+ Email DID
+
+
+ -
+ Import the UCAN they send you.
+
+ Instruct your friend to use the web console or CLI to create a UCAN, delegating your DID access to their space.
+
+
+
+
+
+ {importedDelegation && importedDelegation.capabilities && importedDelegation.capabilities.length > 0 && (
+
+
Added
+
+
+ Space: {importedDelegation.capabilities[0].with}
+
+
+ Capabilities: {importedDelegation.capabilities.map(c => c.can).join(', ')}
+
+
+
+ )}
+
+ )
+}
+
+// Helper function to truncate CID for display
+function truncateCID(cid: string): string {
+ if (cid.length <= 14) return cid
+ return `${cid.slice(0, 7)}...${cid.slice(-7)}`
+}
+
+export function SharingManager({ spaceDID }: SharingManagerProps) {
+ return (
+
+
+
Share your space
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/console/src/components/SpaceManager.tsx b/packages/console/src/components/SpaceManager.tsx
new file mode 100644
index 000000000..ca48c8065
--- /dev/null
+++ b/packages/console/src/components/SpaceManager.tsx
@@ -0,0 +1,158 @@
+'use client'
+import React from 'react'
+import Link from 'next/link'
+import { SpacePicker, useSpacePicker } from '@storacha/ui-react'
+import { DidIcon } from './DidIcon'
+import { Logo } from '../brand'
+import { Space } from '@storacha/ui-react'
+
+interface SpaceManagerProps {
+ spaceType?: 'public' | 'private' | 'all'
+ onSpaceSelect?: (space: Space) => void
+}
+
+interface SpaceListDisplayProps {
+ spaces: Space[]
+ type: 'public' | 'private' | 'all'
+}
+
+function SpaceListDisplay({ spaces, type }: SpaceListDisplayProps) {
+ // Note: useSpacePicker is not used in display component since we use Next.js routing
+
+ if (spaces.length === 0) {
+ return (
+
+
No {type === 'all' ? '' : type + ' '}spaces yet.
+
+ Create your first {type === 'all' ? '' : type + ' '}space
+
+
+ )
+ }
+
+ return (
+
+ {spaces.map(space => (
+
+ ))}
+
+ )
+}
+
+interface SpaceListItemProps {
+ space: Space
+}
+
+function SpaceListItem({ space }: SpaceListItemProps) {
+ return (
+
+
+
+
+
+
+
+ {space.name || 'Untitled'}
+
+
+
+ {space.did()}
+
+
+
+ )
+}
+
+function CreateSpaceDialog() {
+ const [{ showCreateDialog, newSpaceName, creatingSpace, createError }, { setShowCreateDialog, createSpace }] = useSpacePicker()
+
+ if (!showCreateDialog) return null
+
+ return (
+
+
+
+
+
+
+ Create New Space
+
+
+
+
+
+
+
+ {createError && (
+
+
{createError.message}
+
+ )}
+
+
+
+ Cancel
+
+
+ {creatingSpace ? 'Creating...' : 'Create Space'}
+
+
+
+
+
+ )
+}
+
+export function SpaceManager({ spaceType = 'all', onSpaceSelect }: SpaceManagerProps) {
+ return (
+
+
+
+
+
+
+ )
+}
+
+interface SpaceManagerContentProps {
+ spaceType: 'public' | 'private' | 'all'
+}
+
+function SpaceManagerContent({ spaceType }: SpaceManagerContentProps) {
+ const [{ spaces }, { setShowCreateDialog }] = useSpacePicker()
+
+ return (
+
+
+
+ {spaceType === 'all' ? 'Your Spaces' : `${spaceType.charAt(0).toUpperCase() + spaceType.slice(1)} Spaces`}
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/console/src/components/UploadsManager.tsx b/packages/console/src/components/UploadsManager.tsx
new file mode 100644
index 000000000..5257ba187
--- /dev/null
+++ b/packages/console/src/components/UploadsManager.tsx
@@ -0,0 +1,178 @@
+'use client'
+import React from 'react'
+import Link from 'next/link'
+import { ChevronLeftIcon, ChevronRightIcon, ArrowPathIcon } from '@heroicons/react/20/solid'
+import { UploadsList, useUploadsList, Space, UnknownLink } from '@storacha/ui-react'
+
+interface UploadsManagerProps {
+ space: Space
+ uploads: Array<{
+ root: UnknownLink
+ updatedAt: string
+ }>
+ loading?: boolean
+ validating?: boolean
+ onUploadSelect?: (root: UnknownLink) => void
+ onNext?: () => void
+ onPrev?: () => void
+ onRefresh?: () => void
+}
+
+function UploadsTable() {
+ const [{ uploads, loading }] = useUploadsList()
+
+ return (
+
+
+
+
+ | Root CID |
+ Timestamp |
+
+
+
+ {uploads.map((upload, i) => (
+
+
+
+ {upload.root.toString()}
+
+
+
+
+ {new Date(upload.updatedAt).toLocaleString()}
+
+
+
+ ))}
+
+
+
+ )
+}
+
+function UploadsNavigation({ space }: { space: Space }) {
+ const [{ loading, validating }, { previousPage, nextPage, refresh }] = useUploadsList()
+
+ return (
+
+
+
+
+
+ {loading || validating ? 'Loading' : 'Reload'}
+
+
+
+
+ )
+}
+
+function EmptyUploads({ space }: { space: Space }) {
+ const [{ loading }] = useUploadsList()
+
+ return (
+
+ {!loading && (
+
+ No uploads. Upload a file.
+
+ )}
+
+
+ )
+}
+
+export function UploadsManager({
+ space,
+ uploads,
+ loading = false,
+ validating = false,
+ onUploadSelect,
+ onNext,
+ onPrev,
+ onRefresh
+}: UploadsManagerProps) {
+ const handleRefresh = async () => {
+ if (onRefresh) {
+ onRefresh()
+ }
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+interface UploadsManagerContentProps {
+ space: Space
+ uploads: Array<{
+ root: UnknownLink
+ updatedAt: string
+ }>
+ loading: boolean
+ validating: boolean
+}
+
+function UploadsManagerContent({ space, uploads, loading, validating }: UploadsManagerContentProps) {
+ const [, { setUploads, setLoading }] = useUploadsList()
+
+ // Sync external state with internal UploadsList state
+ React.useEffect(() => {
+ setUploads(uploads.map(upload => ({
+ root: upload.root,
+ updatedAt: upload.updatedAt
+ })))
+ }, [uploads, setUploads])
+
+ React.useEffect(() => {
+ setLoading(loading)
+ }, [loading, setLoading])
+
+ const hasUploads = uploads && uploads.length > 0
+
+ if (!hasUploads) {
+ return
+ }
+
+ return (
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/packages/react/src/SharingTools.tsx b/packages/ui/packages/react/src/SharingTools.tsx
new file mode 100644
index 000000000..2ad3351df
--- /dev/null
+++ b/packages/ui/packages/react/src/SharingTools.tsx
@@ -0,0 +1,593 @@
+import type { As, Component, Props, Options } from 'ariakit-react-utils'
+import type { SpaceDID } from '@storacha/ui-core'
+import type { ChangeEvent, FormEventHandler } from 'react'
+import type { Delegation, Capabilities } from '@ucanto/interface'
+
+import {
+ Fragment,
+ useState,
+ createContext,
+ useContext,
+ useCallback,
+ useMemo,
+} from 'react'
+import { createComponent, createElement } from 'ariakit-react-utils'
+import { useW3 } from './providers/Provider.js'
+
+export interface DelegationItem {
+ email: string
+ capabilities: string[]
+ delegation: Delegation
+ revoked?: boolean
+}
+
+export interface SharingToolsContextState {
+ /**
+ * Space being shared
+ */
+ spaceDID?: SpaceDID
+ /**
+ * Current input value for email/DID
+ */
+ shareValue: string
+ /**
+ * List of shared delegations
+ */
+ delegations: DelegationItem[]
+ /**
+ * Loading shared delegations
+ */
+ loadingDelegations: boolean
+ /**
+ * Set of emails currently being revoked
+ */
+ revokingEmails: Set
+ /**
+ * Error during sharing operation
+ */
+ shareError?: string
+ /**
+ * File for import (UCAN delegation)
+ */
+ importFile?: File
+ /**
+ * Successfully imported delegation
+ */
+ importedDelegation?: Delegation
+}
+
+export interface SharingToolsContextActions {
+ /**
+ * Set the space to share
+ */
+ setSpaceDID: (spaceDID: SpaceDID) => void
+ /**
+ * Set share input value
+ */
+ setShareValue: (value: string) => void
+ /**
+ * Share space via email
+ */
+ shareViaEmail: (email: string) => Promise
+ /**
+ * Create delegation download for DID
+ */
+ createDelegationDownload: (did: string) => Promise
+ /**
+ * Revoke delegation
+ */
+ revokeDelegation: (email: string, delegation: Delegation) => Promise
+ /**
+ * Set import file
+ */
+ setImportFile: (file?: File) => void
+ /**
+ * Import UCAN delegation
+ */
+ importDelegation: (file: File) => Promise
+ /**
+ * Refresh delegations list
+ */
+ refreshDelegations: () => Promise
+ /**
+ * Clear share error
+ */
+ clearShareError: () => void
+}
+
+export type SharingToolsContextValue = [
+ state: SharingToolsContextState,
+ actions: SharingToolsContextActions
+]
+
+export const SharingToolsContextDefaultValue: SharingToolsContextValue = [
+ {
+ shareValue: '',
+ delegations: [],
+ loadingDelegations: false,
+ revokingEmails: new Set(),
+ },
+ {
+ setSpaceDID: () => {
+ throw new Error('missing set space DID function')
+ },
+ setShareValue: () => {
+ throw new Error('missing set share value function')
+ },
+ shareViaEmail: async () => {
+ throw new Error('missing share via email function')
+ },
+ createDelegationDownload: async () => {
+ throw new Error('missing create delegation download function')
+ },
+ revokeDelegation: async () => {
+ throw new Error('missing revoke delegation function')
+ },
+ setImportFile: () => {
+ throw new Error('missing set import file function')
+ },
+ importDelegation: async () => {
+ throw new Error('missing import delegation function')
+ },
+ refreshDelegations: async () => {
+ throw new Error('missing refresh delegations function')
+ },
+ clearShareError: () => {
+ throw new Error('missing clear share error function')
+ },
+ },
+]
+
+export const SharingToolsContext = createContext(
+ SharingToolsContextDefaultValue
+)
+
+export type SharingToolsRootOptions = Options & {
+ /**
+ * Space DID to share
+ */
+ spaceDID?: SpaceDID
+ /**
+ * Callback when space is successfully shared
+ */
+ onShare?: (delegation: DelegationItem) => void
+ /**
+ * Callback when delegation is successfully revoked
+ */
+ onRevoke?: (email: string) => void
+ /**
+ * Callback when delegation is successfully imported
+ */
+ onImport?: (delegation: Delegation) => void
+}
+
+export type SharingToolsRootProps = Props<
+ SharingToolsRootOptions
+>
+
+/**
+ * Top level component of the headless SharingTools.
+ *
+ * Must be used inside a w3ui Provider.
+ */
+export const SharingToolsRoot: Component =
+ createComponent(({ spaceDID: initialSpaceDID, onShare, onRevoke, onImport, ...props }) => {
+ const [{ client }] = useW3()
+ const [spaceDID, setSpaceDID] = useState(initialSpaceDID)
+ const [shareValue, setShareValue] = useState('')
+ const [delegations, setDelegations] = useState([])
+ const [loadingDelegations, setLoadingDelegations] = useState(false)
+ const [revokingEmails, setRevokingEmails] = useState>(new Set())
+ const [shareError, setShareError] = useState()
+ const [importFile, setImportFile] = useState()
+ const [importedDelegation, setImportedDelegation] = useState>()
+
+
+
+ const shareViaEmail = useCallback(async (email: string) => {
+ if (!client || !spaceDID) {
+ throw new Error('Client or space not available')
+ }
+
+ const space = client.spaces().find(s => s.did() === spaceDID)
+ if (!space) {
+ throw new Error('Could not find space to share')
+ }
+
+ // Check if email already has a revoked delegation
+ const existingRevokedDelegation = delegations.find(item =>
+ item.email === email && item.revoked
+ )
+
+ if (existingRevokedDelegation) {
+ setShareError(`Cannot grant access to ${email}. This email has a previously revoked delegation. Revoked delegations cannot be reactivated.`)
+ return
+ }
+
+ setShareError(undefined)
+
+ try {
+ const delegation = await client.shareSpace(email as `${string}@${string}`, space.did())
+ const delegationItem: DelegationItem = {
+ email,
+ capabilities: delegation.capabilities.map((c: any) => c.can),
+ delegation
+ }
+
+ setDelegations(prev => [...prev, delegationItem])
+ setShareValue('')
+ onShare?.(delegationItem)
+ } catch (error) {
+ setShareError(`Failed to share space: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+ }, [client, spaceDID, delegations, onShare])
+
+ const createDelegationDownload = useCallback(async (did: string): Promise => {
+ if (!client) {
+ throw new Error('Client not available')
+ }
+
+ const { DID } = await import('@ucanto/core')
+ const audienceDID = DID.parse(did)
+ const delegation = await client.createDelegation(audienceDID, [
+ 'space/*',
+ 'store/*',
+ 'upload/*',
+ 'access/*',
+ 'usage/*',
+ 'filecoin/*',
+ ], {
+ expiration: Infinity,
+ })
+
+ const archiveRes = await delegation.archive()
+ if (archiveRes.error) {
+ throw new Error('Failed to archive delegation', { cause: archiveRes.error })
+ }
+
+ const blob = new Blob([archiveRes.ok])
+ return URL.createObjectURL(blob)
+ }, [client])
+
+ const revokeDelegation = useCallback(async (email: string, delegation: Delegation) => {
+ if (!client) {
+ throw new Error('Client not available')
+ }
+
+ try {
+ setRevokingEmails(prev => new Set([...prev, email]))
+ await client.revokeDelegation(delegation.cid)
+
+ // Mark delegation as revoked instead of removing
+ setDelegations(prev => prev.map(item =>
+ item.email === email ? { ...item, revoked: true } : item
+ ))
+
+ onRevoke?.(email)
+ } catch (error) {
+ throw error
+ } finally {
+ setRevokingEmails(prev => {
+ const next = new Set(prev)
+ next.delete(email)
+ return next
+ })
+ }
+ }, [client, onRevoke])
+
+ const importDelegation = useCallback(async (file: File) => {
+ if (!client) {
+ throw new Error('Client not available')
+ }
+
+ try {
+ const arrayBuffer = await file.arrayBuffer()
+ const { extract } = await import('@ucanto/core/delegation')
+
+ const res = await extract(new Uint8Array(arrayBuffer))
+ if (res.error) {
+ throw new Error('Failed to extract delegation', { cause: res.error })
+ }
+
+ const delegation = res.ok
+ await client.addSpace(delegation)
+
+ setImportedDelegation(delegation)
+ setImportFile(undefined)
+ onImport?.(delegation)
+ } catch (error) {
+ throw new Error(`Failed to import delegation: ${error instanceof Error ? error.message : 'Unknown error'}`)
+ }
+ }, [client, onImport])
+
+ const refreshDelegations = useCallback(async () => {
+ if (!client || !spaceDID) return
+
+ setLoadingDelegations(true)
+ try {
+ // This would need to be implemented based on the specific client API
+ // for fetching existing delegations for a space
+ // For now, we'll keep the existing delegations
+ } finally {
+ setLoadingDelegations(false)
+ }
+ }, [client, spaceDID])
+
+ const clearShareError = useCallback(() => {
+ setShareError(undefined)
+ }, [])
+
+ const value = useMemo(
+ () => [
+ {
+ spaceDID,
+ shareValue,
+ delegations,
+ loadingDelegations,
+ revokingEmails,
+ shareError,
+ importFile,
+ importedDelegation,
+ },
+ {
+ setSpaceDID,
+ setShareValue,
+ shareViaEmail,
+ createDelegationDownload,
+ revokeDelegation,
+ setImportFile,
+ importDelegation,
+ refreshDelegations,
+ clearShareError,
+ },
+ ],
+ [
+ spaceDID,
+ shareValue,
+ delegations,
+ loadingDelegations,
+ revokingEmails,
+ shareError,
+ importFile,
+ importedDelegation,
+ shareViaEmail,
+ createDelegationDownload,
+ revokeDelegation,
+ importDelegation,
+ refreshDelegations,
+ clearShareError,
+ ]
+ )
+
+ return (
+
+ {createElement(Fragment, props)}
+
+ )
+ })
+
+export type ShareFormOptions = Options
+export type ShareFormProps = Props>
+
+/**
+ * Form for sharing a space
+ */
+export const ShareForm: Component =
+ createComponent((props) => {
+ const [{ shareValue }, { shareViaEmail, createDelegationDownload }] = useSharingTools()
+
+ const isDID = (value: string): boolean => {
+ try {
+ return /^did:[a-z0-9]+:[a-zA-Z0-9._%-]+$/i.test(value.trim())
+ } catch {
+ return false
+ }
+ }
+
+ const isEmail = (value: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return !isDID(value) && emailRegex.test(value)
+ }
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (e) => {
+ e.preventDefault()
+ if (isEmail(shareValue)) {
+ await shareViaEmail(shareValue)
+ } else if (isDID(shareValue)) {
+ const url = await createDelegationDownload(shareValue)
+ // Trigger download
+ const link = document.createElement('a')
+ link.href = url
+ link.download = `delegation-${shareValue.split(':')[2]?.substring(0, 10) || 'unknown'}.ucan`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+ },
+ [shareValue, shareViaEmail, createDelegationDownload]
+ )
+
+ return createElement('form', {
+ ...props,
+ onSubmit: handleSubmit,
+ })
+ })
+
+export type ShareInputOptions = Options
+export type ShareInputProps = Props>
+
+/**
+ * Input for email or DID to share with
+ */
+export const ShareInput: Component =
+ createComponent((props) => {
+ const [{ shareValue }, { setShareValue, clearShareError }] = useSharingTools()
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ setShareValue(e.target.value)
+ clearShareError()
+ },
+ [setShareValue, clearShareError]
+ )
+
+ return createElement('input', {
+ ...props,
+ type: 'text',
+ value: shareValue,
+ onChange: handleChange,
+ placeholder: 'email or did:...',
+ })
+ })
+
+export type ShareButtonOptions = Options
+export type ShareButtonProps = Props>
+
+/**
+ * Button to submit share form
+ */
+export const ShareButton: Component =
+ createComponent((props) => {
+ const [{ shareValue }] = useSharingTools()
+
+ const isDID = (value: string): boolean => {
+ try {
+ return /^did:[a-z0-9]+:[a-zA-Z0-9._%-]+$/i.test(value.trim())
+ } catch {
+ return false
+ }
+ }
+
+ const isEmail = (value: string): boolean => {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return !isDID(value) && emailRegex.test(value)
+ }
+
+ const isValid = isEmail(shareValue) || isDID(shareValue)
+
+ return createElement('button', {
+ ...props,
+ type: 'submit',
+ disabled: !isValid,
+ })
+ })
+
+export type DelegationListOptions = Options
+export type DelegationListProps = Props>
+
+/**
+ * List of shared delegations
+ */
+export const DelegationList: Component =
+ createComponent((props) => {
+ return createElement('div', { ...props, role: 'list' })
+ })
+
+export type DelegationItemOptions = Options & {
+ delegation: DelegationItem
+}
+export type DelegationItemProps = Props>
+
+/**
+ * Individual delegation item
+ */
+export const DelegationItemComponent: Component =
+ createComponent(({ delegation, ...props }) => {
+ return createElement('div', { ...props, role: 'listitem' })
+ })
+
+export type RevokeButtonOptions = Options & {
+ delegation: DelegationItem
+}
+export type RevokeButtonProps = Props>
+
+/**
+ * Button to revoke a delegation
+ */
+export const RevokeButton: Component =
+ createComponent(({ delegation, ...props }) => {
+ const [{ revokingEmails }, { revokeDelegation }] = useSharingTools()
+
+ const handleClick = useCallback(async () => {
+ await revokeDelegation(delegation.email, delegation.delegation)
+ }, [delegation, revokeDelegation])
+
+ const isRevoking = revokingEmails.has(delegation.email)
+
+ return createElement('button', {
+ ...props,
+ onClick: handleClick,
+ disabled: isRevoking || delegation.revoked,
+ })
+ })
+
+export type ImportFormOptions = Options
+export type ImportFormProps = Props>
+
+/**
+ * Form for importing UCAN delegations
+ */
+export const ImportForm: Component =
+ createComponent((props) => {
+ const [{ importFile }, { importDelegation }] = useSharingTools()
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (e) => {
+ e.preventDefault()
+ if (importFile) {
+ await importDelegation(importFile)
+ }
+ },
+ [importFile, importDelegation]
+ )
+
+ return createElement('form', {
+ ...props,
+ onSubmit: handleSubmit,
+ })
+ })
+
+export type ImportInputOptions = Options
+export type ImportInputProps = Props>
+
+/**
+ * File input for importing UCAN delegations
+ */
+export const ImportInput: Component =
+ createComponent((props) => {
+ const [, { setImportFile }] = useSharingTools()
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ const file = e.target.files?.[0]
+ setImportFile(file)
+ },
+ [setImportFile]
+ )
+
+ return createElement('input', {
+ ...props,
+ type: 'file',
+ accept: '.ucan,.car,application/vnd.ipfs.car',
+ onChange: handleChange,
+ })
+ })
+
+/**
+ * Use the scoped sharing tools context state from a parent `SharingTools`.
+ */
+export function useSharingTools(): SharingToolsContextValue {
+ return useContext(SharingToolsContext)
+}
+
+export const SharingTools = Object.assign(SharingToolsRoot, {
+ ShareForm: ShareForm,
+ ShareInput: ShareInput,
+ ShareButton: ShareButton,
+ DelegationList: DelegationList,
+ DelegationItem: DelegationItemComponent,
+ RevokeButton: RevokeButton,
+ ImportForm: ImportForm,
+ ImportInput: ImportInput,
+})
\ No newline at end of file
diff --git a/packages/ui/packages/react/src/SpacePicker.tsx b/packages/ui/packages/react/src/SpacePicker.tsx
new file mode 100644
index 000000000..b399a97d8
--- /dev/null
+++ b/packages/ui/packages/react/src/SpacePicker.tsx
@@ -0,0 +1,370 @@
+import type { As, Component, Props, Options } from 'ariakit-react-utils'
+import type { Space } from '@storacha/ui-core'
+import type { ChangeEvent, FormEventHandler } from 'react'
+
+import {
+ Fragment,
+ useState,
+ createContext,
+ useContext,
+ useCallback,
+ useMemo,
+} from 'react'
+import { createComponent, createElement } from 'ariakit-react-utils'
+import { useW3 } from './providers/Provider.js'
+
+export interface SpacePickerContextState {
+ /**
+ * Available spaces for the current user
+ */
+ spaces: Space[]
+ /**
+ * Currently selected space
+ */
+ selectedSpace?: Space
+ /**
+ * Is the create space dialog open?
+ */
+ showCreateDialog: boolean
+ /**
+ * Name for new space being created
+ */
+ newSpaceName: string
+ /**
+ * Is a space creation in progress?
+ */
+ creatingSpace: boolean
+ /**
+ * Error during space creation
+ */
+ createError?: Error
+}
+
+export interface SpacePickerContextActions {
+ /**
+ * Select a space
+ */
+ selectSpace: (space: Space) => void
+ /**
+ * Open/close create space dialog
+ */
+ setShowCreateDialog: (show: boolean) => void
+ /**
+ * Set new space name
+ */
+ setNewSpaceName: (name: string) => void
+ /**
+ * Create a new space
+ */
+ createSpace: () => Promise
+}
+
+export type SpacePickerContextValue = [
+ state: SpacePickerContextState,
+ actions: SpacePickerContextActions
+]
+
+export const SpacePickerContextDefaultValue: SpacePickerContextValue = [
+ {
+ spaces: [],
+ showCreateDialog: false,
+ newSpaceName: '',
+ creatingSpace: false,
+ },
+ {
+ selectSpace: () => {
+ throw new Error('missing select space function')
+ },
+ setShowCreateDialog: () => {
+ throw new Error('missing set show create dialog function')
+ },
+ setNewSpaceName: () => {
+ throw new Error('missing set new space name function')
+ },
+ createSpace: async () => {
+ throw new Error('missing create space function')
+ },
+ },
+]
+
+export const SpacePickerContext = createContext(
+ SpacePickerContextDefaultValue
+)
+
+export type SpacePickerRootOptions = Options & {
+ /**
+ * Callback when a space is selected
+ */
+ onSpaceSelect?: (space: Space) => void
+ /**
+ * Filter spaces by type
+ */
+ spaceType?: 'public' | 'private' | 'all'
+}
+
+export type SpacePickerRootProps = Props<
+ SpacePickerRootOptions
+>
+
+/**
+ * Top level component of the headless SpacePicker.
+ *
+ * Must be used inside a w3ui Provider.
+ */
+export const SpacePickerRoot: Component =
+ createComponent(({ onSpaceSelect, spaceType = 'all', ...props }) => {
+ const [{ client, spaces: allSpaces }] = useW3()
+ const [selectedSpace, setSelectedSpace] = useState()
+ const [showCreateDialog, setShowCreateDialog] = useState(false)
+ const [newSpaceName, setNewSpaceName] = useState('')
+ const [creatingSpace, setCreatingSpace] = useState(false)
+ const [createError, setCreateError] = useState()
+
+ // Filter spaces based on spaceType
+ const spaces = useMemo(() => {
+ if (spaceType === 'all') return allSpaces
+ return allSpaces.filter(space => {
+ const access = space.meta()?.access
+ if (spaceType === 'private') return access?.type === 'private'
+ if (spaceType === 'public') return access?.type !== 'private'
+ return true
+ })
+ }, [allSpaces, spaceType])
+
+ const selectSpace = useCallback((space: Space) => {
+ setSelectedSpace(space)
+ onSpaceSelect?.(space)
+ }, [onSpaceSelect])
+
+ const createSpace = useCallback(async () => {
+ if (!client) {
+ throw new Error('Client not available')
+ }
+
+ setCreatingSpace(true)
+ setCreateError(undefined)
+
+ try {
+ const space = await client.createSpace(newSpaceName || '') as any
+ setNewSpaceName('')
+ setShowCreateDialog(false)
+ selectSpace(space)
+ } catch (error) {
+ setCreateError(error instanceof Error ? error : new Error(String(error)))
+ } finally {
+ setCreatingSpace(false)
+ }
+ }, [client, newSpaceName, selectSpace])
+
+ const value = useMemo(
+ () => [
+ {
+ spaces,
+ selectedSpace,
+ showCreateDialog,
+ newSpaceName,
+ creatingSpace,
+ createError,
+ },
+ {
+ selectSpace,
+ setShowCreateDialog,
+ setNewSpaceName,
+ createSpace,
+ },
+ ],
+ [
+ spaces,
+ selectedSpace,
+ showCreateDialog,
+ newSpaceName,
+ creatingSpace,
+ createError,
+ selectSpace,
+ createSpace,
+ ]
+ )
+
+ return (
+
+ {createElement(Fragment, props)}
+
+ )
+ })
+
+export type SpacePickerListOptions = Options
+export type SpacePickerListProps = Props<
+ SpacePickerListOptions
+>
+
+/**
+ * List container for spaces
+ */
+export const SpacePickerList: Component =
+ createComponent((props) => {
+ return createElement('div', { ...props, role: 'list' })
+ })
+
+export type SpacePickerItemOptions = Options & {
+ space: Space
+}
+export type SpacePickerItemProps = Props<
+ SpacePickerItemOptions
+>
+
+/**
+ * Individual space item in the list
+ */
+export const SpacePickerItem: Component =
+ createComponent(({ space, ...props }) => {
+ const [{ selectedSpace }, { selectSpace }] = useSpacePicker()
+
+ const handleClick = useCallback(() => {
+ selectSpace(space)
+ }, [space, selectSpace])
+
+ const isSelected = selectedSpace?.did() === space.did()
+
+ return createElement('button', {
+ ...props,
+ onClick: handleClick,
+ 'aria-selected': isSelected,
+ role: 'listitem',
+ })
+ })
+
+export type SpacePickerCreateDialogOptions = Options
+export type SpacePickerCreateDialogProps = Props<
+ SpacePickerCreateDialogOptions
+>
+
+/**
+ * Dialog for creating a new space
+ */
+export const SpacePickerCreateDialog: Component =
+ createComponent((props) => {
+ const [{ showCreateDialog }] = useSpacePicker()
+
+ if (!showCreateDialog) {
+ return createElement('div', { style: { display: 'none' } })
+ }
+
+ return createElement('div', {
+ ...props,
+ role: 'dialog',
+ 'aria-modal': true,
+ })
+ })
+
+export type SpacePickerCreateFormOptions = Options
+export type SpacePickerCreateFormProps = Props<
+ SpacePickerCreateFormOptions
+>
+
+/**
+ * Form for creating a new space
+ */
+export const SpacePickerCreateForm: Component =
+ createComponent((props) => {
+ const [, { createSpace }] = useSpacePicker()
+
+ const handleSubmit: FormEventHandler = useCallback(
+ async (e) => {
+ e.preventDefault()
+ await createSpace()
+ },
+ [createSpace]
+ )
+
+ return createElement('form', {
+ ...props,
+ onSubmit: handleSubmit,
+ })
+ })
+
+export type SpacePickerNameInputOptions = Options
+export type SpacePickerNameInputProps = Props<
+ SpacePickerNameInputOptions
+>
+
+/**
+ * Input for space name
+ */
+export const SpacePickerNameInput: Component =
+ createComponent((props) => {
+ const [{ newSpaceName }, { setNewSpaceName }] = useSpacePicker()
+
+ const handleChange = useCallback(
+ (e: ChangeEvent) => {
+ setNewSpaceName(e.target.value)
+ },
+ [setNewSpaceName]
+ )
+
+ return createElement('input', {
+ ...props,
+ type: 'text',
+ value: newSpaceName,
+ onChange: handleChange,
+ })
+ })
+
+export type SpacePickerCreateButtonOptions = Options
+export type SpacePickerCreateButtonProps = Props<
+ SpacePickerCreateButtonOptions
+>
+
+/**
+ * Button to trigger space creation
+ */
+export const SpacePickerCreateButton: Component =
+ createComponent((props) => {
+ const [{ creatingSpace }] = useSpacePicker()
+
+ return createElement('button', {
+ ...props,
+ type: 'submit',
+ disabled: creatingSpace,
+ })
+ })
+
+export type SpacePickerCancelButtonOptions = Options
+export type SpacePickerCancelButtonProps = Props<
+ SpacePickerCancelButtonOptions
+>
+
+/**
+ * Button to cancel space creation
+ */
+export const SpacePickerCancelButton: Component =
+ createComponent((props) => {
+ const [, { setShowCreateDialog, setNewSpaceName }] = useSpacePicker()
+
+ const handleClick = useCallback(() => {
+ setShowCreateDialog(false)
+ setNewSpaceName('')
+ }, [setShowCreateDialog, setNewSpaceName])
+
+ return createElement('button', {
+ ...props,
+ type: 'button',
+ onClick: handleClick,
+ })
+ })
+
+/**
+ * Use the scoped space picker context state from a parent `SpacePicker`.
+ */
+export function useSpacePicker(): SpacePickerContextValue {
+ return useContext(SpacePickerContext)
+}
+
+export const SpacePicker = Object.assign(SpacePickerRoot, {
+ List: SpacePickerList,
+ Item: SpacePickerItem,
+ CreateDialog: SpacePickerCreateDialog,
+ CreateForm: SpacePickerCreateForm,
+ NameInput: SpacePickerNameInput,
+ CreateButton: SpacePickerCreateButton,
+ CancelButton: SpacePickerCancelButton,
+})
\ No newline at end of file
diff --git a/packages/ui/packages/react/src/UploadsList.tsx b/packages/ui/packages/react/src/UploadsList.tsx
new file mode 100644
index 000000000..e86ccbae6
--- /dev/null
+++ b/packages/ui/packages/react/src/UploadsList.tsx
@@ -0,0 +1,436 @@
+import type { As, Component, Props, Options } from 'ariakit-react-utils'
+import type { Space, UnknownLink } from '@storacha/ui-core'
+import type { MouseEventHandler } from 'react'
+
+import {
+ Fragment,
+ useState,
+ createContext,
+ useContext,
+ useCallback,
+ useMemo,
+} from 'react'
+import { createComponent, createElement } from 'ariakit-react-utils'
+
+export interface UploadItem {
+ root: UnknownLink
+ updatedAt: string
+ shards?: Array<{ cid: string; size: number }>
+}
+
+export interface UploadsListContextState {
+ /**
+ * List of uploads
+ */
+ uploads: UploadItem[]
+ /**
+ * Currently selected upload
+ */
+ selectedUpload?: UploadItem
+ /**
+ * Is the list loading?
+ */
+ loading: boolean
+ /**
+ * Is the list being validated/refreshed?
+ */
+ validating: boolean
+ /**
+ * Current page (0-indexed)
+ */
+ currentPage: number
+ /**
+ * Items per page
+ */
+ pageSize: number
+ /**
+ * Total number of items
+ */
+ totalItems?: number
+ /**
+ * Error state
+ */
+ error?: Error
+}
+
+export interface UploadsListContextActions {
+ /**
+ * Select an upload
+ */
+ selectUpload: (upload: UploadItem) => void
+ /**
+ * Go to next page
+ */
+ nextPage: () => void
+ /**
+ * Go to previous page
+ */
+ previousPage: () => void
+ /**
+ * Go to specific page
+ */
+ goToPage: (page: number) => void
+ /**
+ * Refresh the list
+ */
+ refresh: () => void
+ /**
+ * Set loading state
+ */
+ setLoading: (loading: boolean) => void
+ /**
+ * Set uploads data
+ */
+ setUploads: (uploads: UploadItem[]) => void
+ /**
+ * Set error state
+ */
+ setError: (error?: Error) => void
+}
+
+export type UploadsListContextValue = [
+ state: UploadsListContextState,
+ actions: UploadsListContextActions
+]
+
+export const UploadsListContextDefaultValue: UploadsListContextValue = [
+ {
+ uploads: [],
+ loading: false,
+ validating: false,
+ currentPage: 0,
+ pageSize: 25,
+ },
+ {
+ selectUpload: () => {
+ throw new Error('missing select upload function')
+ },
+ nextPage: () => {
+ throw new Error('missing next page function')
+ },
+ previousPage: () => {
+ throw new Error('missing previous page function')
+ },
+ goToPage: () => {
+ throw new Error('missing go to page function')
+ },
+ refresh: () => {
+ throw new Error('missing refresh function')
+ },
+ setLoading: () => {
+ throw new Error('missing set loading function')
+ },
+ setUploads: () => {
+ throw new Error('missing set uploads function')
+ },
+ setError: () => {
+ throw new Error('missing set error function')
+ },
+ },
+]
+
+export const UploadsListContext = createContext(
+ UploadsListContextDefaultValue
+)
+
+export type UploadsListRootOptions = Options & {
+ /**
+ * Space to fetch uploads from
+ */
+ space?: Space
+ /**
+ * Callback when an upload is selected
+ */
+ onUploadSelect?: (upload: UploadItem) => void
+ /**
+ * Custom refresh function
+ */
+ onRefresh?: () => Promise | void
+ /**
+ * Items per page
+ */
+ pageSize?: number
+}
+
+export type UploadsListRootProps = Props<
+ UploadsListRootOptions
+>
+
+/**
+ * Top level component of the headless UploadsList.
+ *
+ * Must be used inside a w3ui Provider.
+ */
+export const UploadsListRoot: Component =
+ createComponent(({ space, onUploadSelect, onRefresh, pageSize = 25, ...props }) => {
+ const [uploads, setUploads] = useState([])
+ const [selectedUpload, setSelectedUpload] = useState()
+ const [loading, setLoading] = useState(false)
+ const [validating, setValidating] = useState(false)
+ const [currentPage, setCurrentPage] = useState(0)
+ const [totalItems] = useState()
+ const [error, setError] = useState()
+
+ const selectUpload = useCallback((upload: UploadItem) => {
+ setSelectedUpload(upload)
+ onUploadSelect?.(upload)
+ }, [onUploadSelect])
+
+ const nextPage = useCallback(() => {
+ const maxPage = totalItems ? Math.ceil(totalItems / pageSize) - 1 : 0
+ setCurrentPage(prev => Math.min(prev + 1, maxPage))
+ }, [totalItems, pageSize])
+
+ const previousPage = useCallback(() => {
+ setCurrentPage(prev => Math.max(prev - 1, 0))
+ }, [])
+
+ const goToPage = useCallback((page: number) => {
+ const maxPage = totalItems ? Math.ceil(totalItems / pageSize) - 1 : 0
+ setCurrentPage(Math.max(0, Math.min(page, maxPage)))
+ }, [totalItems, pageSize])
+
+ const refresh = useCallback(async () => {
+ if (onRefresh) {
+ setValidating(true)
+ try {
+ await onRefresh()
+ } finally {
+ setValidating(false)
+ }
+ }
+ }, [onRefresh])
+
+
+ const value = useMemo(
+ () => [
+ {
+ uploads,
+ selectedUpload,
+ loading,
+ validating,
+ currentPage,
+ pageSize,
+ totalItems,
+ error,
+ },
+ {
+ selectUpload,
+ nextPage,
+ previousPage,
+ goToPage,
+ refresh,
+ setLoading,
+ setUploads,
+ setError,
+ },
+ ],
+ [
+ uploads,
+ selectedUpload,
+ loading,
+ validating,
+ currentPage,
+ pageSize,
+ totalItems,
+ error,
+ selectUpload,
+ nextPage,
+ previousPage,
+ goToPage,
+ refresh,
+ ]
+ )
+
+ return (
+
+ {createElement(Fragment, props)}
+
+ )
+ })
+
+export type UploadsListTableOptions = Options
+export type UploadsListTableProps = Props<
+ UploadsListTableOptions
+>
+
+/**
+ * Table component for displaying uploads
+ */
+export const UploadsListTable: Component =
+ createComponent((props) => {
+ return createElement('table', props)
+ })
+
+export type UploadsListHeaderOptions = Options
+export type UploadsListHeaderProps = Props<
+ UploadsListHeaderOptions
+>
+
+/**
+ * Table header component
+ */
+export const UploadsListHeader: Component =
+ createComponent((props) => {
+ return createElement('thead', props)
+ })
+
+export type UploadsListBodyOptions = Options
+export type UploadsListBodyProps = Props<
+ UploadsListBodyOptions
+>
+
+/**
+ * Table body component
+ */
+export const UploadsListBody: Component =
+ createComponent((props) => {
+ return createElement('tbody', props)
+ })
+
+export type UploadsListRowOptions = Options & {
+ upload: UploadItem
+}
+export type UploadsListRowProps = Props<
+ UploadsListRowOptions
+>
+
+/**
+ * Table row component for an individual upload
+ */
+export const UploadsListRow: Component =
+ createComponent(({ upload, ...props }) => {
+ const [{ selectedUpload }, { selectUpload }] = useUploadsList()
+
+ const handleClick: MouseEventHandler = useCallback((e) => {
+ e.preventDefault()
+ selectUpload(upload)
+ }, [upload, selectUpload])
+
+ const isSelected = selectedUpload?.root.toString() === upload.root.toString()
+
+ return createElement('tr', {
+ ...props,
+ onClick: handleClick,
+ 'aria-selected': isSelected,
+ role: 'button',
+ tabIndex: 0,
+ })
+ })
+
+export type UploadsListCellOptions = Options
+export type UploadsListCellProps = Props<
+ UploadsListCellOptions
+>
+
+/**
+ * Table cell component
+ */
+export const UploadsListCell: Component =
+ createComponent((props) => {
+ return createElement('td', props)
+ })
+
+export type UploadsListPaginationOptions = Options
+export type UploadsListPaginationProps = Props<
+ UploadsListPaginationOptions
+>
+
+/**
+ * Pagination navigation component
+ */
+export const UploadsListPagination: Component =
+ createComponent((props) => {
+ return createElement('nav', { ...props, role: 'navigation', 'aria-label': 'Uploads pagination' })
+ })
+
+export type UploadsListPreviousButtonOptions = Options
+export type UploadsListPreviousButtonProps = Props<
+ UploadsListPreviousButtonOptions
+>
+
+/**
+ * Previous page button
+ */
+export const UploadsListPreviousButton: Component =
+ createComponent((props) => {
+ const [{ loading }, { previousPage }] = useUploadsList()
+
+ const handleClick = useCallback(() => {
+ previousPage()
+ }, [previousPage])
+
+ return createElement('button', {
+ ...props,
+ onClick: handleClick,
+ disabled: loading,
+ })
+ })
+
+export type UploadsListNextButtonOptions = Options
+export type UploadsListNextButtonProps = Props<
+ UploadsListNextButtonOptions
+>
+
+/**
+ * Next page button
+ */
+export const UploadsListNextButton: Component =
+ createComponent((props) => {
+ const [{ loading }, { nextPage }] = useUploadsList()
+
+ const handleClick = useCallback(() => {
+ nextPage()
+ }, [nextPage])
+
+ return createElement('button', {
+ ...props,
+ onClick: handleClick,
+ disabled: loading,
+ })
+ })
+
+export type UploadsListRefreshButtonOptions = Options
+export type UploadsListRefreshButtonProps = Props<
+ UploadsListRefreshButtonOptions
+>
+
+/**
+ * Refresh button
+ */
+export const UploadsListRefreshButton: Component =
+ createComponent((props) => {
+ const [{ loading, validating }, { refresh }] = useUploadsList()
+
+ const handleClick = useCallback(() => {
+ refresh()
+ }, [refresh])
+
+ const isLoading = loading || validating
+
+ return createElement('button', {
+ ...props,
+ onClick: handleClick,
+ disabled: isLoading,
+ 'aria-label': isLoading ? 'Refreshing uploads...' : 'Refresh uploads',
+ })
+ })
+
+/**
+ * Use the scoped uploads list context state from a parent `UploadsList`.
+ */
+export function useUploadsList(): UploadsListContextValue {
+ return useContext(UploadsListContext)
+}
+
+export const UploadsList = Object.assign(UploadsListRoot, {
+ Table: UploadsListTable,
+ Header: UploadsListHeader,
+ Body: UploadsListBody,
+ Row: UploadsListRow,
+ Cell: UploadsListCell,
+ Pagination: UploadsListPagination,
+ PreviousButton: UploadsListPreviousButton,
+ NextButton: UploadsListNextButton,
+ RefreshButton: UploadsListRefreshButton,
+})
\ No newline at end of file
diff --git a/packages/ui/packages/react/src/index.ts b/packages/ui/packages/react/src/index.ts
index 47ec9c12f..de2660136 100644
--- a/packages/ui/packages/react/src/index.ts
+++ b/packages/ui/packages/react/src/index.ts
@@ -2,4 +2,7 @@ export * from '@storacha/ui-core'
export * from './providers/Provider.js'
export * from './Authenticator.js'
export * from './Uploader.js'
+export * from './SpacePicker.js'
+export * from './UploadsList.js'
+export * from './SharingTools.js'
export * from './hooks.js'
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b5781f528..47d217540 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -817,6 +817,9 @@ importers:
'@cloudflare/next-on-pages':
specifier: ^1.6.3
version: 1.13.12(vercel@33.2.0(@swc/core@1.11.11(@swc/helpers@0.5.15))(encoding@0.1.13))(wrangler@3.114.7)
+ '@playwright/test':
+ specifier: ^1.51.1
+ version: 1.51.1
'@types/archy':
specifier: ^0.0.36
version: 0.0.36
@@ -2566,7 +2569,7 @@ packages:
'@cloudflare/next-on-pages@1.13.12':
resolution: {integrity: sha512-rPy7x9c2+0RDDdJ5o0TeRUwXJ1b7N1epnqF6qKSp5Wz1r9KHOyvaZh1ACoOC6Vu5k9su5WZOgy+8fPLIyrldMQ==}
- deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+ deprecated: 'Please use the OpenNext adapter instead: https://opennext.js.org/cloudflare'
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20240208.0