Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion public/locales/en/explore.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,14 @@
}
},
"errors": {
"BlockFetchTimeoutError": "Failed to fetch content in {timeout}s. Please refresh the page to retry or try a different CID."
"BlockFetchTimeoutError": "Failed to fetch content in {timeout}s. Please refresh the page to retry or try a different CID/CAR file.",
"CARFetchError": "Failed to import CAR file: {message}",
"checkIpfsNetwork": "Check content availability on IPFS Network",
"troubleshootingTips": {
"title": "Troubleshooting Tips",
"checkCarFormat": "Check if the file is a valid CAR format",
"tryDifferentCar": "Try uploading a different CAR file"
},
"clearError": "Okay, take me back to start exploring page"
}
}
46 changes: 32 additions & 14 deletions src/components/StartExploringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next'
import ReactJoyride from 'react-joyride'
import { type ExplorePageLink, explorePageLinks } from '../lib/explore-page-suggestions.js'
import { projectsTour } from '../lib/tours.js'
import { useExplore } from '../providers/explore.js'
import { AboutIpld } from './about/AboutIpld.js'
import { IpldExploreErrorComponent } from './explore/IpldExploreErrorComponent.js'
import IpldExploreForm from './explore/IpldExploreForm.js'
import { type NodeStyle, colorForNode, nameForNode, shortNameForNode } from './object-info/ObjectInfo.js'

Expand All @@ -29,6 +31,9 @@ interface StartExploringPageProps {

export const StartExploringPage: React.FC<StartExploringPageProps> = ({ embed, runTour = false, joyrideCallback, links }) => {
const { t } = useTranslation('explore')
const { exploreState } = useExplore()

const { error } = exploreState

return (
<div className='mw9 center explore-sug-2'>
Expand All @@ -37,22 +42,35 @@ export const StartExploringPage: React.FC<StartExploringPageProps> = ({ embed, r
</Helmet>
<div className='flex-l'>
<div className='flex-auto-l mr3-l'>
<div className='pl3 pl0-l pt4 pt2-l'>
<h1 className='f3 f2-l ma0 fw4 montserrat charcoal'>{t('StartExploringPage.header')}</h1>
<p className='lh-copy f5 avenir charcoal-muted'>{t('StartExploringPage.leadParagraph')}</p>
</div>
{error == null && (
<div className='pl3 pl0-l pt4 pt2-l'>
<h1 className='f3 f2-l ma0 fw4 montserrat charcoal'>{t('StartExploringPage.header')}</h1>
<p className='lh-copy f5 avenir charcoal-muted'>{t('StartExploringPage.leadParagraph')}</p>
</div>
)}
{embed != null ? <IpldExploreForm /> : null}
<ul className='list pl0 ma0 mt4 mt0-l bt bn-l b--black-10'>
{(links ?? explorePageLinks).map((suggestion) => (
<li key={suggestion.cid}>
<ExploreSuggestion name={suggestion.name} cid={suggestion.cid} type={suggestion.type} />
</li>
))}
</ul>
</div>
<div className='pt2-l'>
<AboutIpld />
{error != null
? (
<div className='pl3 pl0-l pt4 pt2-l'>
<IpldExploreErrorComponent error={error} />
</div>
)
: null}
{error == null && (
<ul className='list pl0 ma0 mt4 mt0-l bt bn-l b--black-10'>
{(links ?? explorePageLinks).map((suggestion) => (
<li key={suggestion.cid}>
<ExploreSuggestion name={suggestion.name} cid={suggestion.cid} type={suggestion.type} />
</li>
))}
</ul>
)}
</div>
{error == null && (
<div className='pt2-l'>
<AboutIpld />
</div>
)}
</div>

<ReactJoyride
Expand Down
2 changes: 1 addition & 1 deletion src/components/explore/IpldCarExploreForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import uploadImage from './upload.svg'

export const IpldCarExploreForm: React.FC = () => {
const { t } = useTranslation('explore')
// const [file, setFile] = useState({})
const { doUploadUserProvidedCar } = useExplore()
const { selectHeliaReady } = useHelia()

Expand Down Expand Up @@ -35,6 +34,7 @@ export const IpldCarExploreForm: React.FC = () => {
console.error('no file selected')
return
}

void doUploadUserProvidedCar(selectedFile, uploadImage)
}, [doUploadUserProvidedCar])

Expand Down
49 changes: 44 additions & 5 deletions src/components/explore/IpldExploreErrorComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,57 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { CARFetchError } from '../../lib/errors'
import { useExplore } from '../../providers/explore.js'
import type IpldExploreError from '../../lib/errors'
import type { ExploreState } from '../../providers/explore.js'

export interface IpldExploreErrorComponentProps {
error: IpldExploreError | null
}

export function IpldExploreErrorComponent ({ error }: IpldExploreErrorComponentProps): JSX.Element | null {
const CIDTroubleshootingTips: React.FC = () => {
const { t } = useTranslation('explore', { keyPrefix: 'errors' })
if (error == null) return null
const { setExploreState } = useExplore()

const handleClearError = (): void => {
const hashSplit = window.location.hash.split('/')
if (hashSplit.length > 2) {
window.location.hash = '#/explore'
}
setExploreState((state: ExploreState) => ({ ...state, path: hashSplit.length > 2 ? '/' : state.path, error: null }))
}

return (
<div className='bg-red white pa3 lh-copy'>
<div>{error.toString(t)}</div>
</div>
<>
<div className='mt3'>
<h4 className='ma0 mb2'>{t('troubleshootingTips.title')}</h4>
<ul className='ma0 pl3'>
<li>{t('troubleshootingTips.checkCarFormat')}</li>
<li>{t('troubleshootingTips.tryDifferentCar')}</li>
</ul>
</div>
<div className='mt2'>
<button
onClick={handleClearError}
className='red-dark hover-white underline pointer bg-transparent bn'
>
{t('clearError')}
</button>
</div>
</>
)
}

export function IpldExploreErrorComponent ({ error }: IpldExploreErrorComponentProps): JSX.Element | null {
const { t } = useTranslation('explore', { keyPrefix: 'errors' })
if (error == null) return null

return <div className="flex justify-center w-100 pa3">
<div className="bg-red-muted red-dark pa3 br2 lh-copy mw7">
<div>{error.toString(t)}</div>
{error instanceof CARFetchError
? <CIDTroubleshootingTips />
: null}
</div>
</div>
}
2 changes: 2 additions & 0 deletions src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ export default class IpldExploreError extends Error {
}

export class BlockFetchTimeoutError extends IpldExploreError {}

export class CARFetchError extends IpldExploreError {}
45 changes: 35 additions & 10 deletions src/lib/import-car.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,47 @@ import { type Helia } from '@helia/interface'
import { CarBlockIterator } from '@ipld/car'
import { type CID } from 'multiformats'
import { source } from 'stream-to-it'
import { BlockFetchTimeoutError } from './errors.js'

/**
* Given a file object representing a CAR archive, import it into the given Helia instance,
* and return the CID for the root block
*
* TODO: Handle multiple roots
*/
export async function importCar (file: File, helia: Helia): Promise<CID> {
const inStream = file.stream()
const CarIterator = await CarBlockIterator.fromIterable(source(inStream))
for await (const { cid, bytes } of CarIterator) {
// add blocks to helia to ensure they are available while navigating children
await helia.blockstore.put(cid, bytes)
}
const cidRoots = await CarIterator.getRoots()
export async function importCar (file: File, helia: Helia, timeout = 30000): Promise<CID> {
const controller = new AbortController()
const { signal } = controller

const timeoutId = setTimeout(() => {
controller.abort('Request timed out')
}, timeout)

try {
const inStream = file.stream()

// @todo: Handle multiple roots
return cidRoots[0]
const CarIterator = await CarBlockIterator.fromIterable(source(inStream))

for await (const { cid, bytes } of CarIterator) {
if (signal.aborted) {
throw new BlockFetchTimeoutError({ timeout: timeout / 1000 })
}
// add blocks to helia to ensure they are available while navigating children
await helia.blockstore.put(cid, bytes)
}

const cidRoots = await CarIterator.getRoots()
if (cidRoots.length === 0) {
throw new Error('Invalid CAR file: no roots found')
}
// @todo: Handle multiple roots
return cidRoots[0]
} catch (err) {
if (err instanceof Error && err.message === 'Unexpected end of data') {
throw new Error('Invalid CAR file')
}
throw new Error(err instanceof Error ? err.message : 'Failed to import CAR file')
} finally {
clearTimeout(timeoutId)
}
}
34 changes: 29 additions & 5 deletions src/providers/explore.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CID } from 'multiformats/cid'
import React, { createContext, useContext, useState, useEffect, type ReactNode, useCallback } from 'react'
import { type LinkObject } from '../components/object-info/links-table'
import { CARFetchError } from '../lib/errors.js'
import { ensureLeadingSlash } from '../lib/helpers.js'
import { importCar } from '../lib/import-car.js'
import { parseIpldPath } from '../lib/parse-ipld-path.js'
Expand All @@ -17,6 +18,7 @@ interface ExploreContextProps {
doExploreLink(link: any): void
doExploreUserProvidedPath(path: string): void
doUploadUserProvidedCar(file: File, uploadImage: string): Promise<void>
setExploreState: React.Dispatch<React.SetStateAction<ExploreState>>
}

export interface ExploreState {
Expand Down Expand Up @@ -185,26 +187,48 @@ export const ExploreProvider = ({ children, state, explorePathPrefix = '#/explor
}

const doUploadUserProvidedCar = useCallback(async (file: File, uploadImage: string): Promise<void> => {
const resolveLoader = (image: string): void => {
const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement
if (imageFileLoader != null) {
imageFileLoader.src = image
}
}

if (helia == null) {
console.error('FIXME: Helia not ready yet, but user tried to upload a car file')
return
}

try {
if (file.size === 0) {
throw new Error('CAR file is empty')
}

const rootCid = await importCar(file, helia)
const hash = rootCid.toString() != null ? `${explorePathPrefix}${ensureLeadingSlash(rootCid.toString())}` : explorePathPrefix
window.location.hash = hash

const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement
if (imageFileLoader != null) {
imageFileLoader.src = uploadImage
}
resolveLoader(uploadImage)
} catch (err) {
console.error('Could not import car file', err)

setExploreState((prevState) => ({
...prevState,
targetNode: null,
error: new CARFetchError({ message: err instanceof Error ? err.message : 'Failed to import CAR file' })
}))

resolveLoader(uploadImage)

const carFileInputEl = document.getElementById('car-file') as HTMLInputElement
if (carFileInputEl != null) {
carFileInputEl.value = ''
}
}
}, [explorePathPrefix, helia])

return (
<ExploreContext.Provider value={{ exploreState, explorePathPrefix, isLoading, doExploreLink, doExploreUserProvidedPath, doUploadUserProvidedCar, setExplorePath }}>
<ExploreContext.Provider value={{ exploreState, explorePathPrefix, isLoading, doExploreLink, doExploreUserProvidedPath, doUploadUserProvidedCar, setExplorePath, setExploreState }}>
{children}
</ExploreContext.Provider>
)
Expand Down