diff --git a/.aegir.js b/.aegir.js index efda3f3e..89c99fe9 100644 --- a/.aegir.js +++ b/.aegir.js @@ -1,14 +1,82 @@ +// import copy from 'esbuild-plugin-copy' +import fs from 'node:fs' +import path from 'node:path' + +const copyPlugin = ({ext}) => { + return { + name: `copy-${ext}`, + setup(build) { + const srcDir = 'src' + const destDir = 'dist/src' + build.onEnd(() => { + const copyFile = (src, dest) => { + fs.mkdirSync(path.dirname(dest), { recursive: true }) + fs.copyFileSync(src, dest) + } + + const walkDir = (dir, callback) => { + fs.readdirSync(dir).forEach(f => { + const dirPath = path.join(dir, f) + const isDirectory = fs.statSync(dirPath).isDirectory() + isDirectory ? walkDir(dirPath, callback) : callback(path.join(dir, f)) + }) + } + + walkDir(srcDir, (filePath) => { + if (filePath.endsWith(`.${ext}`)) { + const relativePath = path.relative(srcDir, filePath) + const destPath = path.join(destDir, relativePath) + copyFile(filePath, destPath) + } + }) + }) + } + } +} /** @type {import('aegir').PartialOptions} */ -module.exports = { +export default { + // TODO: fix build and test with aegir + // test: { + // build: false, + // files: [ + // 'dist/test/**/*.spec.{js,ts, jsx, tsx}', + // ], + // }, + build: { + config: { + inject: [ + './src/lib/browser-shims.js' + ], + bundle: true, + loader: { + '.js': 'jsx', + '.ts': 'ts', + '.tsx': 'tsx', + '.jsx': 'jsx', + '.svg': 'text', + // '.css': 'css' + '.woff': 'file', + '.woff2': 'file', + '.eot': 'file', + '.otf': 'file', + }, + platform: 'browser', + target: 'es2022', + format: 'esm', + metafile: true, + plugins: [ + copyPlugin({ext: 'css'}), + copyPlugin({ext: 'svg'}), + ], + } + }, lint: { files: [ - // '!node_modules/**', 'src/**/*.{js,jsx,ts,tsx}', 'test/**/*.{js,jsx,ts,tsx}', - // 'src/**/*.tsx', - // 'src/**/*.js', - // 'src/**/*.jsx', 'dev/**/*.{js,jsx,ts,tsx}', + // TODO: re-enable linting of stories. + '!src/**/*.stories.*', ] }, dependencyCheck: { @@ -19,21 +87,14 @@ module.exports = { 'filesize', 'react-inspector', 'react-joyride', + 'react-helmet', // storybook deps + '@chromatic-com/storybook', '@storybook/addon-actions', '@storybook/addon-coverage', - '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-links', - '@storybook/channels', - '@storybook/core-common', - '@storybook/core-events', - '@storybook/csf-plugin', - '@storybook/csf-tools', - '@storybook/docs-tools', - '@storybook/node-logger', - '@storybook/react-dom-shim', '@storybook/types', // problem with deps @@ -41,6 +102,9 @@ module.exports = { // scripts 'wait-on', + + // vite stuff + 'rollup-plugin-node-polyfills', ], productionIgnorePatterns: [ '.aegir.js', @@ -49,7 +113,7 @@ module.exports = { 'vitest.config.js', '/test', '.storybook', - 'dist-vite' + '**/*.stories.*', ] } } diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 0432560c..00000000 --- a/.babelrc +++ /dev/null @@ -1,26 +0,0 @@ -{ - "presets": [ - ["@babel/preset-env", { - "modules": false, - "targets": "> 2%, not dead" - }], - ["@babel/preset-typescript", { - "isTSX": true, - "allExtensions": true - }], - "@babel/preset-react" - ], - "plugins": [ - "@babel/plugin-proposal-class-properties", - ], - "ignore": [ - "**/*.test.js", - "**/*.test.jsx", - "**/*.test.ts", - "**/*.test.tsx", - "**/*.stories.js", - "**/*.stories.jsx", - "**/*.stories.ts", - "**/*.stories.tsx" - ], -} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 34e13fd6..00000000 --- a/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -# top-most EditorConfig file -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 2 -indent_style = space -insert_final_newline = true -trim_trailing_whitespace = true - -[Makefile] -indent_style = tab - -[*.md] -trim_trailing_whitespace = false diff --git a/.storybook/main.ts b/.storybook/main.ts index b1a75f7b..c30fc5af 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -4,23 +4,32 @@ import { mergeConfig } from 'vite'; import viteConfig from '../vite.config'; const config: StorybookConfig = { - stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ '@storybook/addon-links', - '@storybook/addon-essentials', '@storybook/addon-interactions', - '@storybook/addon-coverage' + '@storybook/addon-coverage', + '@chromatic-com/storybook' ], + framework: { name: '@storybook/react-vite', options: {}, }, - docs: { - autodocs: 'tag', - }, - async viteFinal(config) { - // Merge custom configuration into the default config - return mergeConfig(config, viteConfig); + + // async viteFinal(config) { + // // Merge custom configuration into the default config + // return mergeConfig(config, viteConfig); + // }, + typescript: { + // reactDocgen: 'react-docgen-typescript' + reactDocgen: false + // reactDocgenTypescriptOptions: { + + // } }, + + // docs: {} }; export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 14857c73..4503272e 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -5,21 +5,12 @@ globalThis.Buffer = Buffer // import CSS files import 'ipfs-css' -import 'react-virtualized/styles.css' import 'tachyons' -import '../src/components/loader/Loader.css' -import '../src/components/object-info/LinksTable.css' +import '../src/components/loader/loader.css' +import '../src/components/object-info/links-table.css' const preview: Preview = { - parameters: { - actions: { argTypesRegex: '^on[A-Z].*' }, - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/, - }, - }, - }, + // tags: ['autodocs'] }; export default preview; diff --git a/README.md b/README.md index 6979472a..964af217 100644 --- a/README.md +++ b/README.md @@ -17,47 +17,132 @@ This module was extracted from the [explore.ipld.io](https://github.com/ipfs/exp Install it from npm: ```console -npm install ipld-explorer-components +npm install --save ipld-explorer-components ``` -The ES5 friendly version of the `src` dir is generated to the `dist` dir and the -page components are all provided as named exports so you can import them like so: +There are `peerDependencies` so that the consuming app can pick the versions of common deps. You'll need to add relevant deps to your project. + +### Use it in your project + +You can see an example of how to use these components in the [devPage.jsx](./dev/devPage.jsx) file. + +```jsx +// index.tsx +import React from 'react' +import {render} from 'react-dom' +import MyHeader from './app' + +const PageRenderer = (): React.ReactElement => { + /** + * This is a simple example of listening to the hash change event that occurs when the user clicks around in the content rendered by ExplorePage. + */ + const [route, setRoute] = useState(window.location.hash.slice(1) ?? '/') + + useEffect(() => { + const onHashChange = (): void => { setRoute(window.location.hash.slice(1) ?? '/') } + window.addEventListener('hashchange', onHashChange) + return () => { window.removeEventListener('hashchange', onHashChange) } + }, []) + + const RenderPage: React.FC = () => { + switch (true) { + case route.startsWith('/explore'): + return + case route === '/': + default: + return + } + } + + return ( + + ) +} +const App = (): React.ReactElement => { + return ( + + + + + + + ) +} + +const rootEl = document.getElementById('root') +if (rootEl == null) { + throw new Error('No root element found with the id "root"') +} +const root = createRoot(rootEl) +root.render( + + + +) + +``` + +### Exports provided by this library ```js -import {ExplorePage, StartExploringPage} from `ipld-explorer-components` +import { HeliaProvider, ExploreProvider } from 'ipld-explorer-components/providers' +import { StartExploringPage, ExplorePage } from 'ipld-explorer-components/pages' +import { IpldExploreForm, IpldCarExploreForm } from 'ipld-explorer-components/forms' +// or import all components at once +import { HeliaProvider, ExploreProvider, StartExploringPage, ExplorePage, IpldExploreForm, IpldCarExploreForm, CidInfo, ObjectInfo } from 'ipld-explorer-components' ``` The following Components are available: ```js export { + /** + * Helia provider required for IPLD Explorer components + */ + HeliaProvider, + /** + * A hook to gain access to the Helia node + */ + useHelia, + /** + * Explore provider required for IPLD Explorer components. This must be a child (direct or not) of HeliaProvider. + */ + ExploreProvider, + /** + * A hook to gain access to the Explore state. You can programmatically set the CID or path to explore using the provided functions. + */ + useExplore, + /** + * The page to render when you do not have an explicit CID in the URL to explore yet. + */ StartExploringPage, + /** + * When there is a #/explore/CID in the URL, this component will render the ExplorePage + */ ExplorePage, + /** + * The form to use to allow entry of a CID to explore. You can place this anywhere in your app within the ExploreProvider. + */ IpldExploreForm, + /** + * The form to use to allow uploading of a CAR file to explore. You can place this anywhere in your app within the ExploreProvider. + */ IpldCarExploreForm, CidInfo, - IpldGraph ObjectInfo, - exploreBundle, - heliaBundle } ``` -There are `peerDependencies` so that the parent app can pick the versions of common deps. You'll need to add relevant deps to your project. +### Styling And, assuming you are using `create-react-app` or a similar webpack set up, you'll need the following CSS imports: ```js import 'tachyons' import 'ipfs-css' -import 'react-virtualized/styles.css' -import 'ipld-explorer-components/dist/components/object-info/LinksTable.css' -import 'ipld-explorer-components/dist/components/loader/Loader.css' ``` -You can see an example of how to use these components in the [devPage.jsx](./dev/devPage.jsx) file. - ### Customizing the links displayed in the StartExploringPage To customize the links displayed in the start exploring page, you can pass a `links` property to the `StartExploringPage` component. This property should be an array of objects with the following properties: @@ -70,6 +155,76 @@ To customize the links displayed in the start exploring page, you can pass a `li } ``` +### i18n support + +The translations used for this library are provided in `dist/locales`. You can use them in your project by importing them and passing them to the `i18n` instance in your project. + +```ts +import i18n from 'i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import Backend from 'i18next-chained-backend' +import HttpBackend from 'i18next-http-backend' +import ICU from 'i18next-icu' +import LocalStorageBackend from 'i18next-localstorage-backend' +import { version } from '../package.json' +import locales from './lib/languages.json' + +export const localesList = Object.values(locales) + +await i18n + .use(ICU) + .use(Backend) + .use(LanguageDetector) + .init({ + backend: { + backends: [ + LocalStorageBackend, + HttpBackend + ], + backendOptions: [ + { // LocalStorageBackend + defaultVersion: version, + expirationTime: (!import.meta.env.NODE_ENV || imObjectInfo.publicGatewayport.meta.env.NODE_ENV === 'development') ? 1 : 7 * 24 * 60 * 60 * 1000 + }, + { // HttpBackend + // ensure a relative path is used to look up the locales, so it works when loaded from /ipfs/ + loadPath: (lngs, namespaces) => { + const lang = lngs[0] + const ns = namespaces[0] + if (ns === 'explore') { + // use the ipld-explorer-components locales + return 'node_modules/ipld-explorer-components/dist/locales/{{lng}}/{{ns}}.json' + } + + // you can override keys in the explore namespace with your own translations. If they are not found, the explore translations will be used. + return `locales/${lang}/${ns}.json` + } + } + ] + }, + ns: ['explore', 'app'], + defaultNS: 'app', + fallbackNS: 'explore', // fallback to explore namespace if the key is not found in the app namespace + fallbackLng: { + 'zh-Hans': ['zh-CN', 'en'], + 'zh-Hant': ['zh-TW', 'en'], + zh: ['zh-CN', 'en'], + default: ['en'] + }, + debug: import.meta.env.DEBUG, + // react i18next special options (optional) + react: { + // wait: true, + // useSuspense: false, + bindI18n: 'languageChanged loaded', + bindStore: 'added removed', + nsMode: 'default' + } + }) +``` + +## Development + ### Adding another codec **NOTE:** PRs adding an old IPLDFormat codec would need the old `blockcodec-to-ipld-format` tool, which has many out-of-date deps. We will only accept PRs for adding BlockCodec interface codecs. @@ -96,20 +251,6 @@ To add another hasher, you will need to update all locations containing the comm see https://github.com/ipfs/ipld-explorer-components/pull/395 for an example. -### Redux-bundler requirements - -These components use [redux-bundler](https://reduxbundler.com/), and your app will need to use a redux-bundler provider to propagate the properties and selectors. You can find a basic example in ./dev/devPage.jsx. - -In short, these components export two bundles found in ./src/bundles: `explore` and `heliaBundle`. The explore bundle and components herein have a few redux-bundler selector dependencies that you need to make sure exist and are called properly. - -| Dependent | redux-bundler selector | Notes | -|--------------------|------------------------|---------------------------------------------------------------------------------------------------------------| -| explore bundle | selectHeliaReady | The explore bundle depends on this selector so it knows when the Helia node is available for use | -| explore & other bundles | selectHelia | The explore bundle gets the Helia node via this selector | -| Main page (or any) | doInitHelia | A consuming app needs to call this selector to tell the bundle that provides the Helia node to instantiate it. | - -If you don't want to use the `heliaBundle`, you must adapt the selectors appropriately. - ## Contribute Feel free to dive in! [Open an issue](https://github.com/ipfs/ipld-explorer-components/issues/new) or submit PRs. diff --git a/dev/devPage.jsx b/dev/devPage.jsx deleted file mode 100644 index 58cdb70c..00000000 --- a/dev/devPage.jsx +++ /dev/null @@ -1,116 +0,0 @@ -/* globals globalThis */ -import { Buffer } from 'buffer' -import 'ipfs-css' -import React, { useEffect } from 'react' -import ReactDOM from 'react-dom' -import { I18nextProvider, withTranslation } from 'react-i18next' -import 'react-virtualized/styles.css' -import { composeBundles, createRouteBundle } from 'redux-bundler' -import { Provider as ReduxStoreProvider, connect } from 'redux-bundler-react' -import 'tachyons' -import heliaBundle from '../src/bundles/helia' -import '../src/components/loader/Loader.css' -import '../src/components/object-info/LinksTable.css' -import i18n from '../src/i18n' -import { exploreBundle, ExplorePage, StartExploringPage, IpldExploreForm, IpldCarExploreForm } from '../src/index' - -globalThis.Buffer = Buffer - -const routesBundle = createRouteBundle( - { - '/explore*': ExplorePage, - '/': StartExploringPage, - '': StartExploringPage - }, - { - routeInfoSelector: 'selectHash' - } -) -const getStore = composeBundles( - exploreBundle(), - routesBundle, - heliaBundle -) - -const HeaderComponent = ({ t }) => { - const activeColor = 'navy 0-100' - const inActiveColor = 'navy o-50' - const [exploreFormType, setExploreFormType] = React.useState('cid') - const [cidColor, setCidColor] = React.useState(activeColor) - const [carColor, setCarColor] = React.useState(inActiveColor) - - function handleOnChange (evt) { - setExploreFormType(evt.target.value) - if (evt.target.value === 'cid') { - setCidColor(activeColor) - setCarColor(inActiveColor) - } else { - setCidColor(inActiveColor) - setCarColor(activeColor) - } - } - - return ( - - - {/* */} - - - CID - CAR - - - - {exploreFormType === 'cid' ? : } - - - - { t('appName') } - - - - - - - - - - - ) -} - -const Header = withTranslation('explore')(HeaderComponent) - -const PageRenderer = connect( - 'selectRoute', - 'selectQueryObject', - 'doUpdateUrl', - 'doInitHelia', - (props) => { - const Page = props?.route - const { embed } = props.queryObject - const { doInitHelia } = props - useEffect(() => { - doInitHelia() - }, [doInitHelia]) - - return ( - <> - - - - - > - ) - } -) - -const App = () => ( - - - - - -) - -ReactDOM.render(, document.getElementById('root')) diff --git a/dev/devPage.tsx b/dev/devPage.tsx new file mode 100644 index 00000000..fb0a76d4 --- /dev/null +++ b/dev/devPage.tsx @@ -0,0 +1,110 @@ +/* globals globalThis */ +import 'ipfs-css' +import { Buffer } from 'buffer' +import React, { type MouseEvent, useEffect, useState } from 'react' +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' + +globalThis.Buffer = globalThis.Buffer ?? Buffer + +const HeaderComponent: React.FC = () => { + const activeColor = 'navy 0-100' + const inActiveColor = 'navy o-50' + const [exploreFormType, setExploreFormType] = useState('cid') + const [cidColor, setCidColor] = useState(activeColor) + const [carColor, setCarColor] = useState(inActiveColor) + const { t } = useTranslation('explore') + + function handleOnChange (evt: MouseEvent): void { + const selectedType = evt.currentTarget.getAttribute('data-value') + if (selectedType == null) { + console.error('No data-value attribute found on the button') + return + } + setExploreFormType(selectedType) + + if (selectedType === 'cid') { + setCidColor(activeColor) + setCarColor(inActiveColor) + } else { + setCidColor(inActiveColor) + setCarColor(activeColor) + } + } + + return ( + + + {/* */} + + + CID + CAR + + + + {exploreFormType === 'cid' ? : } + + + + { t('appName') } + + + + {/* SVG content */} + + + + + + ) +} + +const PageRenderer = (): React.ReactElement => { + const [route, setRoute] = useState(window.location.hash.slice(1) ?? '/') + + useEffect(() => { + const onHashChange = (): void => { setRoute(window.location.hash.slice(1) ?? '/') } + window.addEventListener('hashchange', onHashChange) + return () => { window.removeEventListener('hashchange', onHashChange) } + }, []) + + const RenderPage: React.FC = () => { + switch (true) { + case route.startsWith('/explore'): + return + case route === '/': + default: + return + } + } + + return ( + + ) +} + +const App = (): React.ReactElement => { + return ( + + + + + + + ) +} + +const rootEl = document.getElementById('root') +if (rootEl == null) { + throw new Error('No root element found with the id "root"') +} +const root = createRoot(rootEl) +root.render( + + + +) diff --git a/index.html b/index.html index ff872cac..103a5779 100644 --- a/index.html +++ b/index.html @@ -14,11 +14,9 @@ - +