├── .gitignore ├── .storybook ├── main.js └── preview.js ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── components │ ├── IpfsAudio.tsx │ ├── IpfsImage.tsx │ ├── IpfsMedia.tsx │ ├── IpfsVideo.tsx │ └── index.ts ├── index.tsx └── utils.ts ├── stories ├── IpfsAudio.stories.tsx ├── IpfsImage.stories.tsx ├── IpfsMedia.stories.tsx └── IpfsVideo.stories.tsx ├── test └── index.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../stories/**/*.stories.@(ts|tsx|js|jsx)'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 4 | // https://storybook.js.org/docs/react/configure/typescript#mainjs-configuration 5 | typescript: { 6 | check: true, // type-check stories during Storybook build 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 2 | export const parameters = { 3 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 4 | actions: { argTypesRegex: '^on.*' }, 5 | }; 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dhaiwat Pandya 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-ipfs-image 2 | 3 | Easily render images from IPFS. 4 | 5 | ## Installation 6 | 7 | ```bash 8 | npm install react-ipfs-image 9 | 10 | yarn add react-ipfs-image 11 | ``` 12 | 13 | ## Getting started 14 | 15 | ```tsx 16 | import { IpfsImage } from 'react-ipfs-image'; 17 | 18 | const Page = () => { 19 | return ( 20 | <> 21 | {/* works with both of the following formats */} 22 | 23 | 24 | 25 | {/* you can pass in a gateway URL of your choice. the default gateway url is `https://ipfs.infura.io/ipfs` */} 26 | 27 | 28 | {/* you can also pass in *any* props you can pass to a native HTML `img` tag */} 29 | {}} /> 30 | 31 | ) 32 | } 33 | ``` 34 | 35 | This package also has ``, `` and `` that work similarly to ``. 36 | 37 | Made by [Dhaiwat](https://twitter.com/dhaiwat10)! :) 38 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { IpfsImage } from '../.'; 5 | 6 | const App = () => { 7 | return ( 8 |
9 | 10 |
11 | ); 12 | }; 13 | 14 | ReactDOM.render(, document.getElementById('root')); 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.7.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist", 8 | "src" 9 | ], 10 | "engines": { 11 | "node": ">=10" 12 | }, 13 | "scripts": { 14 | "dev": "tsdx watch", 15 | "build": "tsdx build", 16 | "test": "tsdx test --passWithNoTests", 17 | "lint": "tsdx lint", 18 | "prepare": "tsdx build", 19 | "size": "size-limit", 20 | "analyze": "size-limit --why", 21 | "storybook": "start-storybook -p 6006", 22 | "build-storybook": "build-storybook" 23 | }, 24 | "peerDependencies": { 25 | "react": ">=16" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "tsdx lint" 30 | } 31 | }, 32 | "prettier": { 33 | "printWidth": 80, 34 | "semi": true, 35 | "singleQuote": true, 36 | "trailingComma": "es5" 37 | }, 38 | "name": "react-ipfs-image", 39 | "author": "Dhaiwat Pandya", 40 | "module": "dist/react-ipfs-image.esm.js", 41 | "size-limit": [ 42 | { 43 | "path": "dist/react-ipfs-image.cjs.production.min.js", 44 | "limit": "10 KB" 45 | }, 46 | { 47 | "path": "dist/react-ipfs-image.esm.js", 48 | "limit": "10 KB" 49 | } 50 | ], 51 | "devDependencies": { 52 | "@babel/core": "^7.18.6", 53 | "@size-limit/preset-small-lib": "^7.0.8", 54 | "@storybook/addon-essentials": "^6.5.9", 55 | "@storybook/addon-info": "^5.3.21", 56 | "@storybook/addon-links": "^6.5.9", 57 | "@storybook/addons": "^6.5.9", 58 | "@storybook/react": "^6.5.9", 59 | "@types/react": "^18.0.14", 60 | "@types/react-dom": "^18.0.5", 61 | "babel-loader": "^8.2.5", 62 | "husky": "^8.0.1", 63 | "react": "^18.2.0", 64 | "react-dom": "^18.2.0", 65 | "react-is": "^18.2.0", 66 | "size-limit": "^7.0.8", 67 | "tsdx": "^0.14.1", 68 | "tslib": "^2.4.0", 69 | "typescript": "^4.7.4" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/IpfsAudio.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, MediaHTMLAttributes } from 'react'; 2 | import { cleanUpHash, DEFAULT_IPFS_GATEWAY_URL } from '../utils'; 3 | 4 | export interface IIpfsAudioProps extends MediaHTMLAttributes { 5 | hash: string; 6 | gatewayUrl?: string; 7 | } 8 | 9 | export const IpfsAudio: FC = ({ 10 | hash, 11 | gatewayUrl = DEFAULT_IPFS_GATEWAY_URL, 12 | ...props 13 | }) => { 14 | return ( 15 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/IpfsImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLAttributes, FC } from 'react'; 2 | import { cleanUpHash, DEFAULT_IPFS_GATEWAY_URL } from '../utils'; 3 | 4 | export interface IIpfsImageProps extends HTMLAttributes { 5 | hash: string; 6 | gatewayUrl?: string; 7 | } 8 | 9 | export const IpfsImage: FC = ({ 10 | hash, 11 | gatewayUrl = DEFAULT_IPFS_GATEWAY_URL, 12 | ...props 13 | }) => { 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/IpfsMedia.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { FC } from 'react'; 3 | import { IIpfsVideoProps, IpfsVideo } from './IpfsVideo'; 4 | import { IIpfsImageProps, IpfsImage } from './IpfsImage'; 5 | 6 | export type IIpfsMediaProps = IIpfsVideoProps & IIpfsImageProps; 7 | 8 | export const IpfsMedia: FC = ({ 9 | hash, 10 | gatewayUrl, 11 | ...props 12 | }) => { 13 | const [imgError, setImgError] = useState(false); 14 | const [vidError, setVidError] = useState(false); 15 | 16 | useEffect(() => { 17 | setImgError(false); 18 | setVidError(false); 19 | }, [hash]); 20 | 21 | return ( 22 | <> 23 | {!imgError && ( 24 | setImgError(true)} {...props} /> 25 | )} 26 | {!vidError && ( 27 | setVidError(true)} {...props} /> 28 | )} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/IpfsVideo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, MediaHTMLAttributes } from 'react'; 2 | import { cleanUpHash, DEFAULT_IPFS_GATEWAY_URL } from '../utils'; 3 | 4 | export interface IIpfsVideoProps extends MediaHTMLAttributes { 5 | hash: string; 6 | gatewayUrl?: string; 7 | } 8 | 9 | export const IpfsVideo: FC = ({ 10 | hash, 11 | gatewayUrl = DEFAULT_IPFS_GATEWAY_URL, 12 | ...props 13 | }) => { 14 | return ( 15 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './IpfsImage'; 2 | export * from './IpfsVideo'; 3 | export * from './IpfsMedia'; 4 | export * from './IpfsAudio'; 5 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export { useImgUris, getImgUriFromTokenUriHash } from './utils'; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const DEFAULT_IPFS_GATEWAY_URL = 'https://infura-ipfs.io/ipfs'; 4 | 5 | export const cleanUpHash = (hash: string) => { 6 | // remove the leading 'ipfs://' if it exists 7 | if (hash.startsWith('ipfs://')) { 8 | return hash.substring(7); 9 | } 10 | return hash; 11 | }; 12 | 13 | export const getImgUriFromTokenUriHash = async ( 14 | tokenUriHash: string, 15 | ipfsGatewayUrl = DEFAULT_IPFS_GATEWAY_URL 16 | ) => { 17 | const ipfsHash = cleanUpHash(tokenUriHash); 18 | const ipfsUrl = `${ipfsGatewayUrl}/${ipfsHash}`; 19 | const res = await (await fetch(ipfsUrl)).json(); 20 | return res.image as string; 21 | }; 22 | 23 | export const useImgUris = (tokenUriHashes: string[]) => { 24 | const [imgURIs, setImgURIs] = useState([]); 25 | 26 | useEffect(() => { 27 | const fetchImgUris = async () => { 28 | const imgUris = await Promise.all( 29 | tokenUriHashes.map(uriHash => getImgUriFromTokenUriHash(uriHash)) 30 | ); 31 | setImgURIs(imgUris); 32 | }; 33 | fetchImgUris(); 34 | }, [tokenUriHashes]); 35 | 36 | return imgURIs; 37 | }; 38 | -------------------------------------------------------------------------------- /stories/IpfsAudio.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import { IIpfsAudioProps, IpfsAudio } from '../src'; 4 | 5 | const meta: Meta = { 6 | title: 'IpfsAudio', 7 | component: IpfsAudio, 8 | argTypes: { 9 | hash: { 10 | control: { 11 | type: 'text', 12 | }, 13 | }, 14 | gatewayUrl: { 15 | control: { 16 | type: 'text', 17 | }, 18 | }, 19 | }, 20 | parameters: { 21 | controls: { expanded: true }, 22 | }, 23 | }; 24 | 25 | export default meta; 26 | 27 | const Template: Story = args => ; 28 | 29 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 30 | // https://storybook.js.org/docs/react/workflows/unit-testing 31 | export const Default = Template.bind({}); 32 | 33 | Default.args = { 34 | hash: 'Qmc1G3UYwryKtfE4Vaq5qoaVinjEmAZzkTzsvW4yKygY2h', 35 | }; 36 | 37 | export const AltGatewayUrl = Template.bind({}); 38 | AltGatewayUrl.args = { 39 | hash: 'Qmc1G3UYwryKtfE4Vaq5qoaVinjEmAZzkTzsvW4yKygY2h', 40 | gatewayUrl: 'https://gateway.pinata.cloud/ipfs', 41 | }; 42 | 43 | export const AltHashFormat = Template.bind({}); 44 | AltHashFormat.args = { 45 | hash: 'ipfs://Qmc1G3UYwryKtfE4Vaq5qoaVinjEmAZzkTzsvW4yKygY2h', 46 | }; 47 | -------------------------------------------------------------------------------- /stories/IpfsImage.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import { IIpfsImageProps, IpfsImage } from '../src'; 4 | 5 | const meta: Meta = { 6 | title: 'IpfsImage', 7 | component: IpfsImage, 8 | argTypes: { 9 | hash: { 10 | control: { 11 | type: 'text', 12 | }, 13 | }, 14 | gatewayUrl: { 15 | control: { 16 | type: 'text', 17 | }, 18 | }, 19 | }, 20 | parameters: { 21 | controls: { expanded: true }, 22 | }, 23 | }; 24 | 25 | export default meta; 26 | 27 | const Template: Story = args => ; 28 | 29 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 30 | // https://storybook.js.org/docs/react/workflows/unit-testing 31 | export const Default = Template.bind({}); 32 | 33 | Default.args = { 34 | hash: 'Qme8SriYgGNoXQzG1qYYZKThv3QTBf7pMJwUpu3gqaqQRH', 35 | }; 36 | 37 | export const AltGatewayUrl = Template.bind({}); 38 | AltGatewayUrl.args = { 39 | hash: 'Qme8SriYgGNoXQzG1qYYZKThv3QTBf7pMJwUpu3gqaqQRH', 40 | gatewayUrl: 'https://gateway.pinata.cloud/ipfs', 41 | }; 42 | 43 | export const AltHashFormat = Template.bind({}); 44 | AltHashFormat.args = { 45 | hash: 'ipfs://Qme8SriYgGNoXQzG1qYYZKThv3QTBf7pMJwUpu3gqaqQRH', 46 | }; 47 | -------------------------------------------------------------------------------- /stories/IpfsMedia.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import { IIpfsMediaProps, IpfsMedia } from '../src'; 4 | 5 | const meta: Meta = { 6 | title: 'IpfsMedia', 7 | component: IpfsMedia, 8 | argTypes: { 9 | hash: { 10 | control: { 11 | type: 'text', 12 | }, 13 | }, 14 | gatewayUrl: { 15 | control: { 16 | type: 'text', 17 | }, 18 | }, 19 | }, 20 | parameters: { 21 | controls: { expanded: true }, 22 | }, 23 | }; 24 | 25 | export default meta; 26 | 27 | const Template: Story = args => ; 28 | 29 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 30 | // https://storybook.js.org/docs/react/workflows/unit-testing 31 | export const Video = Template.bind({}); 32 | 33 | Video.args = { 34 | hash: 'QmcniBv7UQ4gGPQQW2BwbD4ZZHzN3o3tPuNLZCbBchd1zh', 35 | }; 36 | 37 | export const Image = Template.bind({}); 38 | Image.args = { 39 | hash: 'Qme8SriYgGNoXQzG1qYYZKThv3QTBf7pMJwUpu3gqaqQRH', 40 | }; 41 | -------------------------------------------------------------------------------- /stories/IpfsVideo.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Meta, Story } from '@storybook/react'; 3 | import { IIpfsVideoProps, IpfsVideo } from '../src'; 4 | 5 | const meta: Meta = { 6 | title: 'IpfsVideo', 7 | component: IpfsVideo, 8 | argTypes: { 9 | hash: { 10 | control: { 11 | type: 'text', 12 | }, 13 | }, 14 | gatewayUrl: { 15 | control: { 16 | type: 'text', 17 | }, 18 | }, 19 | }, 20 | parameters: { 21 | controls: { expanded: true }, 22 | }, 23 | }; 24 | 25 | export default meta; 26 | 27 | const Template: Story = args => ; 28 | 29 | // By passing using the Args format for exported stories, you can control the props for a component for reuse in a test 30 | // https://storybook.js.org/docs/react/workflows/unit-testing 31 | export const Default = Template.bind({}); 32 | 33 | Default.args = { 34 | hash: 'QmcniBv7UQ4gGPQQW2BwbD4ZZHzN3o3tPuNLZCbBchd1zh', 35 | }; 36 | 37 | export const AltGatewayUrl = Template.bind({}); 38 | AltGatewayUrl.args = { 39 | hash: 'QmcniBv7UQ4gGPQQW2BwbD4ZZHzN3o3tPuNLZCbBchd1zh', 40 | gatewayUrl: 'https://gateway.pinata.cloud/ipfs', 41 | }; 42 | 43 | export const AltHashFormat = Template.bind({}); 44 | AltHashFormat.args = { 45 | hash: 'ipfs://QmcniBv7UQ4gGPQQW2BwbD4ZZHzN3o3tPuNLZCbBchd1zh', 46 | }; 47 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { Default as IpfsImage } from '../stories/IpfsImage.stories'; 4 | 5 | describe('IpfsImage', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render( 9 | , 10 | div 11 | ); 12 | ReactDOM.unmountComponentAtNode(div); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------