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 && ( +
+

{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

+
    +
  1. + Send your DID to your friend. +
    + {client?.did()} +
    + Copy DID + + + Email DID + +
  2. +
  3. + 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. +

    +
    + +
    +
  4. +
+ + {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