diff --git a/dev/devPage.tsx b/dev/devPage.tsx index fb0a76d4..2943afbd 100644 --- a/dev/devPage.tsx +++ b/dev/devPage.tsx @@ -6,7 +6,7 @@ import { createRoot } from 'react-dom/client' import { I18nextProvider, useTranslation } from 'react-i18next' import 'tachyons' import i18n from '../src/i18n.js' -import { ExplorePage, StartExploringPage, IpldExploreForm, IpldCarExploreForm, ExploreProvider, HeliaProvider } from '../src/index.js' +import { ExplorePage, StartExploringPage, IpldExploreForm, IpldCarExploreForm, ExploreProvider, HeliaProvider, useExplore } from '../src/index.js' globalThis.Buffer = globalThis.Buffer ?? Buffer @@ -64,27 +64,22 @@ const HeaderComponent: React.FC = () => { } const PageRenderer = (): React.ReactElement => { - const [route, setRoute] = useState(window.location.hash.slice(1) ?? '/') + const { setExplorePath, exploreState: { path } } = useExplore() useEffect(() => { - const onHashChange = (): void => { setRoute(window.location.hash.slice(1) ?? '/') } + const onHashChange = (): void => { + const newRoute = window.location.hash ?? null + setExplorePath(newRoute) + } window.addEventListener('hashchange', onHashChange) return () => { window.removeEventListener('hashchange', onHashChange) } - }, []) + }, [setExplorePath]) - const RenderPage: React.FC = () => { - switch (true) { - case route.startsWith('/explore'): - return - case route === '/': - default: - return - } + if (path == null || path === '') { + return } - return ( - - ) + return } const App = (): React.ReactElement => { diff --git a/src/components/ExplorePage.stories.tsx b/src/components/ExplorePage.stories.tsx index 38ea832a..3cdf18eb 100644 --- a/src/components/ExplorePage.stories.tsx +++ b/src/components/ExplorePage.stories.tsx @@ -8,7 +8,6 @@ const defaultState: ExploreState = { path: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm', canonicalPath: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm', error: null, - explorePathFromHash: 'QmdmQXB2mzChmMeKY47C43LxUdg1NDJ5MWcKMKxDu7RgQm', targetNode: { type: 'dag-pb', format: 'unixfs', diff --git a/src/components/ExplorePage.tsx b/src/components/ExplorePage.tsx index 09bc5b75..1ad887f4 100644 --- a/src/components/ExplorePage.tsx +++ b/src/components/ExplorePage.tsx @@ -26,9 +26,9 @@ export const ExplorePage = ({ const { t, ready: tReady } = useTranslation('explore') const { exploreState, doExploreLink } = useExplore() - const { explorePathFromHash } = exploreState + const { path } = exploreState - if (explorePathFromHash == null) { + if (path == null) { // No IPLD path to explore so show the intro page console.warn('[IPLD Explorer] ExplorePage loaded without a path to explore') return null diff --git a/src/components/loader/loader.tsx b/src/components/loader/loader.tsx index 4edbee5d..f19226f6 100644 --- a/src/components/loader/loader.tsx +++ b/src/components/loader/loader.tsx @@ -1,7 +1,7 @@ import React from 'react' import styles from './loader.module.css' -export const Loader: React.FC<{ color: string }> = ({ color = 'light', ...props }) => { +export const Loader: React.FC<{ color?: string }> = ({ color = 'light', ...props }) => { const className = `dib ${styles.laBallTrianglePath} la-${color} la-sm` return (
diff --git a/src/providers/explore.tsx b/src/providers/explore.tsx index 67d1fe60..343a1574 100644 --- a/src/providers/explore.tsx +++ b/src/providers/explore.tsx @@ -12,7 +12,9 @@ import type { NormalizedDagNode } from '../types.js' interface ExploreContextProps { exploreState: ExploreState - // explorePathFromHash: string | null + explorePathPrefix: string + isLoading: boolean + setExplorePath(path: string | null): void doExploreLink(link: any): void doExploreUserProvidedPath(path: string): void doUploadUserProvidedCar(file: File, uploadImage: string): Promise @@ -37,7 +39,6 @@ export interface ExploreState { nodes: any[] pathBoundaries: any[] error: IpldExploreError | null - explorePathFromHash: string | null } export const ExploreContext = createContext(undefined) @@ -58,6 +59,24 @@ const getCidFromCidOrFqdn = (cidOrFqdn: CID | string): CID => { return CID.parse(cidOrFqdn) } +const processPath = (path: string | null, pathPrefix: string): string | null => { + let newPath = path + if (newPath != null) { + if (newPath.includes(pathPrefix)) { + newPath = newPath.slice(pathPrefix.length) + } + if (newPath.startsWith('/')) { + newPath = newPath.slice(1) + } + if (newPath === '') { + newPath = null + } else { + newPath = decodeURIComponent(newPath) + } + } + return newPath +} + const defaultState: ExploreState = { path: null, targetNode: null, @@ -65,44 +84,83 @@ const defaultState: ExploreState = { localPath: '', nodes: [], pathBoundaries: [], - error: null, - explorePathFromHash: null + error: null +} + +export interface ExploreProviderProps { + children?: ReactNode | ReactNode[] + state?: Partial + explorePathPrefix?: string } -export const ExploreProvider = ({ children, state = defaultState }: { children?: ReactNode, state?: ExploreState }): React.ReactNode => { - const [exploreState, setExploreState] = useState({ ...state, explorePathFromHash: window.location.hash.slice('#/explore'.length) }) +export const ExploreProvider = ({ children, state, explorePathPrefix = '#/explore' }: ExploreProviderProps): React.ReactNode => { + if (state == null) { + state = { + path: processPath(window.location.hash, explorePathPrefix) + } + } else { + if (state.path === '') { + state.path = null + } else if (state.path != null) { + state.path = processPath(state.path, explorePathPrefix) + } + } + const [exploreState, setExploreState] = useState({ ...defaultState, ...state }) const { helia } = useHelia() - const { explorePathFromHash } = exploreState + const [isLoading, setIsLoading] = useState(false) + const { path } = exploreState - const fetchExploreData = useCallback(async (path: string): Promise => { - // Clear the target node when a new path is requested - setExploreState((exploreState) => ({ - ...exploreState, - targetNode: null - })) - const pathParts = parseIpldPath(path) - if (pathParts == null || helia == null) return + useEffect(() => { + setIsLoading(true); - const { cidOrFqdn, rest } = pathParts - try { - const cid = getCidFromCidOrFqdn(cidOrFqdn) - const { targetNode, canonicalPath, localPath, nodes, pathBoundaries } = await resolveIpldPath(helia, cid, rest) - - setExploreState(({ explorePathFromHash }) => ({ - explorePathFromHash, - path, - targetNode, - canonicalPath, - localPath, - nodes, - pathBoundaries, - error: null + (async () => { + if (path == null || helia == null) { + return + } + // Clear the target node when a new path is requested + setExploreState((exploreState) => ({ + ...exploreState, + targetNode: null })) - } catch (error: any) { - console.warn('Failed to resolve path', path, error) - setExploreState((prevState) => ({ ...prevState, error })) + const pathParts = parseIpldPath(path) + if (pathParts == null || helia == null) return + + const { cidOrFqdn, rest } = pathParts + try { + const cid = getCidFromCidOrFqdn(cidOrFqdn) + const { targetNode, canonicalPath, localPath, nodes, pathBoundaries } = await resolveIpldPath(helia, cid, rest) + + setExploreState((curr) => ({ + ...curr, + targetNode, + canonicalPath, + localPath, + nodes, + pathBoundaries, + error: null + })) + } catch (error: any) { + console.warn('Failed to resolve path', path, error) + setExploreState((prevState) => ({ ...prevState, error })) + } + })().catch((err) => { + console.error('Error fetching explore data', err) + setExploreState((prevState) => ({ ...prevState, error: err })) + }).finally(() => { + setIsLoading(false) + }) + }, [helia, path]) + + const setExplorePath = (path: string | null): void => { + const newPath = processPath(path, explorePathPrefix) + if (newPath != null && !window.location.href.includes(newPath)) { + throw new Error('setExplorePath should only be used to update the state, not the URL. If you are using a routing library that doesn\'t allow you to listen to hashchange events, ensure the URL is updated prior to calling setExplorePath.') } - }, [helia]) + setExploreState((exploreState) => ({ + ...exploreState, + path: newPath + })) + } const doExploreLink = (link: LinkObject): void => { const { nodes, pathBoundaries } = exploreState @@ -114,12 +172,16 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?: } pathParts.unshift(cid) const path = pathParts.map((part) => encodeURIComponent(part)).join('/') - const hash = `#/explore/${path}` + const hash = `${explorePathPrefix}/${path}` window.location.hash = hash + setExplorePath(path) } + /** + * @deprecated - use setExplorePath instead + */ const doExploreUserProvidedPath = (path: string): void => { - const hash = path != null ? `#/explore${ensureLeadingSlash(path)}` : '#/explore' + const hash = path != null ? `${explorePathPrefix}${ensureLeadingSlash(path)}` : explorePathPrefix window.location.hash = hash } @@ -130,7 +192,7 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?: } try { const rootCid = await importCar(file, helia) - const hash = rootCid.toString() != null ? `#/explore${ensureLeadingSlash(rootCid.toString())}` : '#/explore' + const hash = rootCid.toString() != null ? `${explorePathPrefix}${ensureLeadingSlash(rootCid.toString())}` : explorePathPrefix window.location.hash = hash const imageFileLoader = document.getElementById('car-loader-image') as HTMLImageElement @@ -140,42 +202,14 @@ export const ExploreProvider = ({ children, state = defaultState }: { children?: } catch (err) { console.error('Could not import car file', err) } - }, [helia]) - - useEffect(() => { - const handleHashChange = (): void => { - const explorePathFromHash = window.location.hash.slice('#/explore'.length) - - setExploreState((state) => ({ - ...state, - explorePathFromHash - })) - } - - window.addEventListener('hashchange', handleHashChange) - handleHashChange() - - return () => { - window.removeEventListener('hashchange', handleHashChange) - } - }, []) - - useEffect(() => { - // if explorePathFromHash or helia change and are not null, fetch the data - // We need to check for helia because the helia provider is async and may not be ready yet - if (explorePathFromHash != null && helia != null) { - void (async () => { - await fetchExploreData(decodeURIComponent(explorePathFromHash)) - })() - } - }, [helia, explorePathFromHash]) + }, [explorePathPrefix, helia]) if (helia == null) { return } return ( - + {children} )