mirror of
https://github.com/kremalicious/ipfs.git
synced 2024-11-21 09:17:05 +01:00
initial commit
This commit is contained in:
commit
7de52e202d
36
.eslintrc
Normal file
36
.eslintrc
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018,
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"project": "./tsconfig.json",
|
||||
"tsconfigRootDir": "./"
|
||||
},
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"prettier/@typescript-eslint",
|
||||
"prettier/react",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:react/recommended"
|
||||
],
|
||||
"plugins": ["@typescript-eslint", "react"],
|
||||
"rules": {
|
||||
"react/prop-types": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off"
|
||||
},
|
||||
"env": {
|
||||
"es6": true,
|
||||
"browser": true,
|
||||
"jest": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
}
|
||||
}
|
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.DS_Store
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
package-lock.json
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2
|
||||
}
|
20
.travis.yml
Normal file
20
.travis.yml
Normal file
@ -0,0 +1,20 @@
|
||||
dist: xenial
|
||||
sudo: required
|
||||
language: node_js
|
||||
node_js: node
|
||||
|
||||
cache: npm
|
||||
|
||||
before_install:
|
||||
# Fixes an issue where the max file watch count is exceeded, triggering ENOSPC
|
||||
# https://stackoverflow.com/questions/22475849/node-js-error-enospc#32600959
|
||||
- echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p
|
||||
|
||||
# will run `npm install` automatically here
|
||||
|
||||
script:
|
||||
- npm test
|
||||
- npm run build
|
||||
|
||||
notifications:
|
||||
email: false
|
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
Copyright 2019 Matthias Kretschmann
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
41
README.md
Normal file
41
README.md
Normal file
@ -0,0 +1,41 @@
|
||||
# ipfs
|
||||
|
||||
> A public IPFS node & gateway.
|
||||
> [ipfs.kretschmann.io](https://ipfs.kretschmann.io)
|
||||
|
||||
[![Build Status](https://flat.badgen.net/travis/kremalicious/ipfs?icon=travis)](https://travis-ci.com/kremalicious/ipfs)
|
||||
|
||||
This repo holds a simple React app built with [Next.js](https://nextjs.org) serving as the frontpage of [ipfs.kretschmann.io](https://ipfs.kretschmann.io) from where you can add files to IPFS via drag and drop.
|
||||
|
||||
---
|
||||
|
||||
- [Development](#development)
|
||||
- [Production](#production)
|
||||
- [Deployment](#deployment)
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm i
|
||||
npm start
|
||||
```
|
||||
|
||||
Will start a live-reloading local server, reachable under [localhost:3000](http://localhost:3000).
|
||||
|
||||
## Production
|
||||
|
||||
To create a production build, run from the root of the project:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Outputs to `./public`.
|
||||
|
||||
## Deployment
|
||||
|
||||
Every branch is automatically deployed to [Now](https://zeit.co/now) with their GitHub integration. A link to a deployment will appear under each Pull Request.
|
||||
|
||||
The latest deployment of the `master` branch is automatically aliased to `ipfs.kremalicious.now.sh`.
|
2
next-env.d.ts
vendored
Normal file
2
next-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
35
next.config.js
Normal file
35
next.config.js
Normal file
@ -0,0 +1,35 @@
|
||||
const withCSS = require('@zeit/next-css')
|
||||
|
||||
const withSvgr = (nextConfig = {}, nextComposePlugins = {}) => {
|
||||
return Object.assign({}, nextConfig, {
|
||||
webpack(config, options) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: [
|
||||
{
|
||||
loader: '@svgr/webpack',
|
||||
options: {
|
||||
icon: true
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (typeof nextConfig.webpack === 'function') {
|
||||
return nextConfig.webpack(config, options)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = withSvgr(
|
||||
withCSS({
|
||||
cssModules: true,
|
||||
cssLoaderOptions: {
|
||||
importLoaders: 1,
|
||||
localIdentName: '[local]___[hash:base64:5]'
|
||||
}
|
||||
})
|
||||
)
|
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@kremalicious/ipfs",
|
||||
"version": "1.0.0",
|
||||
"description": "Public IPFS Node.",
|
||||
"scripts": {
|
||||
"start": "next dev",
|
||||
"build": "next build",
|
||||
"serve": "next start",
|
||||
"test": "eslint --ignore-path .gitignore 'src/**/*.{ts,tsx}'",
|
||||
"format": "prettier ./src/**/*.{css,yml,js,jsx,ts,tsx,json} --write"
|
||||
},
|
||||
"author": "Matthias Kretschmann <m@kretschmann.io>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zeit/next-css": "^1.0.1",
|
||||
"axios": "^0.19.0",
|
||||
"ipfs-http-client": "^39.0.0",
|
||||
"next": "9.1.1",
|
||||
"next-seo": "^2.1.2",
|
||||
"next-svgr": "0.0.2",
|
||||
"react": "^16.10.2",
|
||||
"react-dom": "^16.10.2",
|
||||
"react-dropzone": "^10.1.10",
|
||||
"use-dark-mode": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/next-seo": "^1.10.0",
|
||||
"@types/node": "^12.11.1",
|
||||
"@types/react": "^16.9.9",
|
||||
"@typescript-eslint/eslint-plugin": "^2.4.0",
|
||||
"@typescript-eslint/parser": "^2.4.0",
|
||||
"eslint": "^6.5.1",
|
||||
"eslint-config-prettier": "^6.4.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-prettier": "^3.1.1",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"prettier": "^1.18.2",
|
||||
"typescript": "^3.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "10.x"
|
||||
}
|
||||
}
|
7
site.config.js
Normal file
7
site.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
title: 'ipfs.kretschmann.io',
|
||||
description: 'A public IPFS Gateway',
|
||||
url: 'https://ipfs.kretschmann.io',
|
||||
ipfsGateway: 'https://ipfs.kretschmann.io',
|
||||
ipfsNodeUri: 'https://ipfs.kretschmann.io:443'
|
||||
}
|
15
src/@types/global.d.ts
vendored
Normal file
15
src/@types/global.d.ts
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: { readonly [key: string]: string }
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
import * as React from 'react'
|
||||
export const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement>
|
||||
>
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
|
||||
declare module 'ipfs-http-client'
|
15
src/Layout.module.css
Normal file
15
src/Layout.module.css
Normal file
@ -0,0 +1,15 @@
|
||||
@import './styles/_variables.css';
|
||||
|
||||
.app {
|
||||
padding: var(--spacer);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
text-align: center;
|
||||
min-height: 100vh;
|
||||
margin: auto;
|
||||
max-width: var(--screen-sm);
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
}
|
45
src/Layout.tsx
Normal file
45
src/Layout.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import Head from 'next/head'
|
||||
import { NextSeo } from 'next-seo'
|
||||
import Footer from './components/Footer'
|
||||
import styles from './Layout.module.css'
|
||||
import { title, description, url } from '../site.config'
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
pageTitle = title
|
||||
}: {
|
||||
children: ReactNode
|
||||
pageTitle?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
|
||||
<NextSeo
|
||||
title={pageTitle}
|
||||
description={description}
|
||||
canonical={url}
|
||||
openGraph={{
|
||||
url,
|
||||
title,
|
||||
description,
|
||||
images: [{ url: `${url}/share.png` }],
|
||||
// eslint-disable-next-line @typescript-eslint/camelcase
|
||||
site_name: title
|
||||
}}
|
||||
twitter={{
|
||||
handle: '@kremalicious',
|
||||
site: '@kremalicious',
|
||||
cardType: 'summary_large_image'
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className={styles.main}>{children}</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
8
src/components/Add.module.css
Normal file
8
src/components/Add.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
.add {
|
||||
width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
101
src/components/Add.tsx
Normal file
101
src/components/Add.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { ipfsNodeUri, ipfsGateway } from '../../site.config'
|
||||
import Dropzone from './Dropzone'
|
||||
import styles from './Add.module.css'
|
||||
import Spinner from './Spinner'
|
||||
import useIpfsApi, { IpfsConfig } from '../hooks/use-ipfs-api'
|
||||
|
||||
export function formatBytes(a: number, b: number) {
|
||||
if (a === 0) return '0 Bytes'
|
||||
const c = 1024
|
||||
const d = b || 2
|
||||
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const f = Math.floor(Math.log(a) / Math.log(c))
|
||||
return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]
|
||||
}
|
||||
|
||||
const { hostname, port, protocol } = new URL(ipfsNodeUri)
|
||||
|
||||
const ipfsConfig: IpfsConfig = {
|
||||
protocol: protocol.replace(':', ''),
|
||||
host: hostname,
|
||||
port: port || '443'
|
||||
}
|
||||
|
||||
async function addToIpfs(
|
||||
files: File[],
|
||||
setFileSizeReceived: (size: string) => void,
|
||||
ipfs: any
|
||||
) {
|
||||
const file = [...files][0]
|
||||
const fileDetails = { path: file.name, content: file }
|
||||
|
||||
const response = await ipfs.add(fileDetails, {
|
||||
wrapWithDirectory: true,
|
||||
progress: (length: number) => setFileSizeReceived(formatBytes(length, 0))
|
||||
})
|
||||
|
||||
// CID of wrapping directory is returned last
|
||||
const cid = `${response[response.length - 1].hash}/${file.name}`
|
||||
return cid
|
||||
}
|
||||
|
||||
export default function Add() {
|
||||
const { ipfs, isIpfsReady, ipfsError } = useIpfsApi(ipfsConfig)
|
||||
const [fileHash, setFileHash] = useState()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [message, setMessage] = useState()
|
||||
const [error, setError] = useState()
|
||||
const [fileSize, setFileSize] = useState()
|
||||
const [fileSizeReceived, setFileSizeReceived] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setMessage(
|
||||
`Adding to IPFS<br />
|
||||
<small>${fileSizeReceived || 0}/${fileSize}</small><br />`
|
||||
)
|
||||
}, [fileSize, fileSizeReceived])
|
||||
|
||||
async function handleOnDrop(acceptedFiles: File[]) {
|
||||
if (!acceptedFiles[0]) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const totalSize = formatBytes(acceptedFiles[0].size, 0)
|
||||
setFileSize(totalSize)
|
||||
|
||||
try {
|
||||
const cid = await addToIpfs(acceptedFiles, setFileSizeReceived, ipfs)
|
||||
if (!cid) return
|
||||
setFileHash(cid)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
setError(`Adding to IPFS failed: ${error.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.add}>
|
||||
{loading ? (
|
||||
<Spinner message={message} />
|
||||
) : fileHash ? (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`${ipfsGateway}/ipfs/${fileHash}`}
|
||||
>
|
||||
ipfs://{fileHash}
|
||||
</a>
|
||||
) : (
|
||||
<Dropzone
|
||||
multiple={false}
|
||||
handleOnDrop={handleOnDrop}
|
||||
disabled={!isIpfsReady}
|
||||
error={error || ipfsError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
47
src/components/Dropzone.module.css
Normal file
47
src/components/Dropzone.module.css
Normal file
@ -0,0 +1,47 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
:root {
|
||||
--border-color: var(--brand-grey-light);
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
--border-color: var(--brand-grey);
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 0.1rem dashed var(--border-color);
|
||||
border-radius: calc(var(--border-radius) * 2);
|
||||
padding: calc(var(--spacer) * 2) var(--spacer);
|
||||
transition: 0.2s ease-out;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone:focus,
|
||||
.dropzone:active {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.dragover {
|
||||
composes: dropzone;
|
||||
border-color: var(--brand-cyan);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
composes: dropzone;
|
||||
border-color: var(--brand-grey-light);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--font-size-small);
|
||||
color: red;
|
||||
}
|
||||
|
||||
.dropzone p {
|
||||
text-align: center;
|
||||
margin-bottom: 0;
|
||||
color: var(--brand-grey);
|
||||
}
|
49
src/components/Dropzone.tsx
Normal file
49
src/components/Dropzone.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import styles from './Dropzone.module.css'
|
||||
|
||||
export default function Dropzone({
|
||||
handleOnDrop,
|
||||
disabled,
|
||||
multiple,
|
||||
error
|
||||
}: {
|
||||
handleOnDrop(files: File[]): void
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
error?: string
|
||||
}) {
|
||||
const onDrop = useCallback(acceptedFiles => handleOnDrop(acceptedFiles), [
|
||||
handleOnDrop
|
||||
])
|
||||
|
||||
const {
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isDragActive,
|
||||
isDragReject
|
||||
} = useDropzone({ onDrop })
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps({
|
||||
className: isDragActive
|
||||
? styles.dragover
|
||||
: disabled
|
||||
? styles.disabled
|
||||
: styles.dropzone
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps({ multiple })} />
|
||||
{isDragActive && !isDragReject ? (
|
||||
`Drop it like it's hot!`
|
||||
) : multiple ? (
|
||||
`Drag 'n' drop some files here, or click to select files`
|
||||
) : error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : (
|
||||
`Drag 'n' drop a file here, or click to select a file`
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
15
src/components/Footer.module.css
Normal file
15
src/components/Footer.module.css
Normal file
@ -0,0 +1,15 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--spacer) 0;
|
||||
padding-bottom: 0;
|
||||
align-self: flex-end;
|
||||
margin-top: var(--spacer);
|
||||
}
|
||||
|
||||
.footer,
|
||||
.footer a {
|
||||
color: var(--color-text);
|
||||
}
|
18
src/components/Footer.tsx
Normal file
18
src/components/Footer.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ThemeSwitch from './ThemeSwitch'
|
||||
import styles from './Footer.module.css'
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<ThemeSwitch />
|
||||
<div>
|
||||
© <span id="year">{year}</span>{' '}
|
||||
<a href="https://matthiaskretschmann.com">Matthias Kretschmann</a> — All
|
||||
Rights Reserved
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
18
src/components/Info.module.css
Normal file
18
src/components/Info.module.css
Normal file
@ -0,0 +1,18 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
.info {
|
||||
font-size: var(--font-size-small);
|
||||
opacity: 0.8;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.info h2 {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text);
|
||||
margin-top: calc(var(--spacer) * 2);
|
||||
margin-left: var(--spacer);
|
||||
margin-right: var(--spacer);
|
||||
display: inline-block;
|
||||
}
|
17
src/components/Info.tsx
Normal file
17
src/components/Info.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import Status from './Status'
|
||||
import styles from './Info.module.css'
|
||||
|
||||
export default function Info() {
|
||||
return (
|
||||
<aside className={styles.info}>
|
||||
<h2>
|
||||
<Status type="gateway" />
|
||||
Gateway
|
||||
</h2>
|
||||
<h2>
|
||||
<Status type="api" /> HTTP API
|
||||
</h2>
|
||||
</aside>
|
||||
)
|
||||
}
|
36
src/components/Spinner.module.css
Normal file
36
src/components/Spinner.module.css
Normal file
@ -0,0 +1,36 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
.spinner {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-top: calc(var(--spacer) * 4);
|
||||
margin-bottom: calc(var(--spacer) / 2);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.spinner:before {
|
||||
content: '';
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: -20px;
|
||||
margin-left: -10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--brand-main);
|
||||
border-top-color: var(--brand-cyan);
|
||||
animation: spinner 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.spinnerMessage {
|
||||
color: var(--brand-grey);
|
||||
padding-top: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
17
src/components/Spinner.tsx
Normal file
17
src/components/Spinner.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import styles from './Spinner.module.css'
|
||||
|
||||
const Spinner = ({ message }: { message?: string }) => {
|
||||
return (
|
||||
<div className={styles.spinner}>
|
||||
{message && (
|
||||
<div
|
||||
className={styles.spinnerMessage}
|
||||
dangerouslySetInnerHTML={{ __html: message }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
28
src/components/Status.module.css
Normal file
28
src/components/Status.module.css
Normal file
@ -0,0 +1,28 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
/* default: red square */
|
||||
.status {
|
||||
width: var(--font-size-mini);
|
||||
height: var(--font-size-mini);
|
||||
display: inline-block;
|
||||
background: #ff4136;
|
||||
margin-right: calc(var(--spacer) / 8);
|
||||
}
|
||||
|
||||
/* yellow triangle */
|
||||
.loading {
|
||||
composes: status;
|
||||
background: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: calc(var(--font-size-mini) / 1.7) solid transparent;
|
||||
border-right: calc(var(--font-size-mini) / 1.7) solid transparent;
|
||||
border-bottom: var(--font-size-mini) solid #ffdc00;
|
||||
}
|
||||
|
||||
/* green circle */
|
||||
.online {
|
||||
composes: status;
|
||||
border-radius: 50%;
|
||||
background: #3d9970;
|
||||
}
|
47
src/components/Status.tsx
Normal file
47
src/components/Status.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { pingUrl } from '../utils'
|
||||
import { ipfsGateway, ipfsNodeUri } from '../../site.config'
|
||||
import styles from './Status.module.css'
|
||||
|
||||
export default function Status({ type }: { type: string }) {
|
||||
const [isOnline, setIsOnline] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
async function ping() {
|
||||
const url =
|
||||
type === 'gateway'
|
||||
? `${ipfsGateway}/ipfs/QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG/readme`
|
||||
: `${ipfsNodeUri}/api/v0/id`
|
||||
|
||||
const ping = await pingUrl(url)
|
||||
setIsLoading(false)
|
||||
isOnline !== ping && setIsOnline(ping)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
ping()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
ping()
|
||||
}, 10000) // run every 10 sec.
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
setIsOnline(false)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const classes = isLoading
|
||||
? styles.loading
|
||||
: isOnline
|
||||
? styles.online
|
||||
: styles.status
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
title={isLoading ? 'Checking...' : isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
)
|
||||
}
|
76
src/components/ThemeSwitch.module.css
Normal file
76
src/components/ThemeSwitch.module.css
Normal file
@ -0,0 +1,76 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
:root {
|
||||
--knob-size: 8px;
|
||||
--knob-space: 1px;
|
||||
}
|
||||
|
||||
.themeSwitch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-bottom: calc(var(--spacer) * var(--line-height));
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.themeSwitch svg {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
margin-top: -0.05rem;
|
||||
fill: var(--brand-grey-light);
|
||||
}
|
||||
|
||||
.themeSwitch svg:last-child {
|
||||
margin-top: -0.1rem;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
}
|
||||
|
||||
.checkboxContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkboxFake {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: calc(var(--knob-size) * 2.5);
|
||||
height: calc(var(--knob-size) + calc(var(--knob-space) * 4));
|
||||
border: 1px solid var(--brand-grey-light);
|
||||
border-radius: 15rem;
|
||||
margin-left: calc(var(--spacer) / 3);
|
||||
margin-right: calc(var(--spacer) / 3);
|
||||
}
|
||||
|
||||
.checkboxFake::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--knob-space);
|
||||
left: var(--knob-space);
|
||||
width: var(--knob-size);
|
||||
height: var(--knob-size);
|
||||
background-color: var(--brand-grey-light);
|
||||
border-radius: 15rem;
|
||||
transition: transform 0.2s var(--easing);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[type='checkbox']:checked + .checkboxContainer .checkboxFake::after {
|
||||
transform: translate3d(100%, 0, 0);
|
||||
}
|
||||
|
||||
[type='checkbox'],
|
||||
.label {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
}
|
55
src/components/ThemeSwitch.tsx
Normal file
55
src/components/ThemeSwitch.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import useDarkMode from 'use-dark-mode'
|
||||
|
||||
import Day from '../images/day.svg'
|
||||
import Night from '../images/night.svg'
|
||||
import styles from './ThemeSwitch.module.css'
|
||||
|
||||
const ThemeToggle = () => (
|
||||
<span id="toggle" className={styles.checkboxContainer} aria-live="assertive">
|
||||
<Day />
|
||||
<span className={styles.checkboxFake} />
|
||||
<Night />
|
||||
</span>
|
||||
)
|
||||
|
||||
const ThemeToggleInput = ({
|
||||
isDark,
|
||||
toggleDark
|
||||
}: {
|
||||
isDark: boolean
|
||||
toggleDark(): void
|
||||
}) => (
|
||||
<input
|
||||
onChange={toggleDark}
|
||||
type="checkbox"
|
||||
name="toggle"
|
||||
value="toggle"
|
||||
aria-describedby="toggle"
|
||||
checked={isDark}
|
||||
/>
|
||||
)
|
||||
|
||||
export default function ThemeSwitch() {
|
||||
const darkMode = useDarkMode(false, {
|
||||
classNameDark: 'dark',
|
||||
classNameLight: 'light'
|
||||
})
|
||||
|
||||
return (
|
||||
<aside className={styles.themeSwitch}>
|
||||
<label
|
||||
htmlFor="toggle"
|
||||
className={styles.checkbox}
|
||||
onClick={darkMode.toggle}
|
||||
>
|
||||
<span className={styles.label}>Toggle Dark Mode</span>
|
||||
<ThemeToggleInput
|
||||
isDark={darkMode.value}
|
||||
toggleDark={darkMode.toggle}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
</label>
|
||||
</aside>
|
||||
)
|
||||
}
|
64
src/hooks/use-ipfs-api.tsx
Normal file
64
src/hooks/use-ipfs-api.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import ipfsClient from 'ipfs-http-client'
|
||||
|
||||
let ipfs: any = null
|
||||
let ipfsVersion = ''
|
||||
|
||||
export interface IpfsConfig {
|
||||
protocol: string
|
||||
host: string
|
||||
port: string
|
||||
}
|
||||
|
||||
function parseHTML(str: string) {
|
||||
const tmp = document.implementation.createHTMLDocument()
|
||||
tmp.body.innerHTML = str
|
||||
return tmp.body.children
|
||||
}
|
||||
|
||||
export default function useIpfsApi(config: IpfsConfig) {
|
||||
const [isIpfsReady, setIpfsReady] = useState(Boolean(ipfs))
|
||||
const [ipfsError, setIpfsError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function initIpfs() {
|
||||
if (ipfs !== null) return
|
||||
// eslint-disable-next-line
|
||||
ipfs = await ipfsClient(config)
|
||||
|
||||
try {
|
||||
const version = await ipfs.version()
|
||||
ipfsVersion = version.version
|
||||
} catch (error) {
|
||||
let { message } = error
|
||||
|
||||
if (!error.status) {
|
||||
const htmlData = parseHTML(error)
|
||||
message = htmlData.item(0)
|
||||
message = message.textContent
|
||||
}
|
||||
|
||||
setIpfsError(`IPFS connection error: ${message}`)
|
||||
setIpfsReady(false)
|
||||
return
|
||||
}
|
||||
setIpfsReady(Boolean(await ipfs.id()))
|
||||
}
|
||||
|
||||
initIpfs()
|
||||
}, [config])
|
||||
|
||||
useEffect(() => {
|
||||
// just like componentWillUnmount()
|
||||
return function cleanup() {
|
||||
if (ipfs) {
|
||||
setIpfsReady(false)
|
||||
ipfs = null
|
||||
ipfsVersion = ''
|
||||
setIpfsError('')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { ipfs, ipfsVersion, isIpfsReady, ipfsError }
|
||||
}
|
3
src/images/day.svg
Normal file
3
src/images/day.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="21" viewBox="0 0 21 21">
|
||||
<path d="M1001,1344.5 C1001,1347.53669 998.535313,1350 995.5,1350 C992.459875,1350 990,1347.53669 990,1344.5 C990,1341.46331 992.459875,1339 995.5,1339 C998.535313,1339 1001,1341.46331 1001,1344.5 Z M995.5,1336.484 C994.633125,1336.484 993.81625,1336.69175 993.035,1337 L993,1337 L995.5,1334 L998,1337 L997.96125,1337 C997.181875,1336.691 996.365,1336.484 995.5,1336.484 Z M995.5,1352.516 C996.365,1352.516 997.181875,1352.30825 997.96125,1352 L998,1352 L995.5,1355 L993,1352 L993.035,1352 C993.81625,1352.309 994.633125,1352.516 995.5,1352.516 Z M1003.516,1344.5 C1003.516,1343.63562 1003.3045,1342.81562 1003,1342.03625 L1003,1342 L1006,1344.5 L1003,1347 L1003,1346.96375 C1003.30525,1346.18438 1003.516,1345.36438 1003.516,1344.5 Z M987.484,1344.5 C987.484,1345.36438 987.69025,1346.18438 988,1346.96375 L988,1347 L985,1344.5 L988,1342 L988,1342.03625 C987.6895,1342.81562 987.484,1343.63562 987.484,1344.5 Z M1001.34229,1350.34229 C1002.03819,1349.65134 1002.55304,1348.85785 1002.96959,1348.0297 L1003,1348 L1003,1352 L999,1352 L999.028289,1351.97242 C999.856436,1351.55233 1000.65205,1351.03819 1001.34229,1350.34229 Z M989.657001,1338.65771 C988.961103,1339.34866 988.447666,1340.14215 988.028289,1340.9703 L988,1341 L988,1337 L992,1337 L991.966761,1337.02758 C991.137907,1337.44767 990.348656,1337.96181 989.657001,1338.65771 Z M989.657709,1350.343 C990.349364,1351.0389 991.138614,1351.55304 991.966761,1351.97242 L992,1352 L988,1352 L988,1348 L988.028289,1348.0297 C988.448373,1348.85856 988.96181,1349.65205 989.657709,1350.343 Z M1001.34229,1338.657 C1000.65205,1337.9611 999.856436,1337.44696 999.028289,1337.02758 L999,1337 L1003,1337 L1003,1341 L1002.96959,1340.9703 C1002.55304,1340.14144 1002.03819,1339.34795 1001.34229,1338.657 Z" transform="translate(-985 -1334)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
3
src/images/night.svg
Normal file
3
src/images/night.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" viewBox="0 0 18 17">
|
||||
<path d="M1057.35991,1345.6935 C1052.91908,1345.6935 1049.32216,1342.19489 1049.32216,1337.88132 C1049.32216,1336.46068 1049.74141,1335.14652 1050.42369,1334 C1046.72047,1335.03741 1044,1338.31568 1044,1342.24655 C1044,1347.00713 1047.97006,1350.86789 1052.86864,1350.86789 C1056.91247,1350.86789 1060.28811,1348.22007 1061.35547,1344.62446 C1060.17313,1345.28549 1058.82157,1345.6935 1057.35991,1345.6935 Z" transform="translate(-1044 -1334)"/>
|
||||
</svg>
|
After Width: | Height: | Size: 539 B |
24
src/pages/index.module.css
Normal file
24
src/pages/index.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
@import '../styles/_variables.css';
|
||||
|
||||
.header {
|
||||
margin-top: 10vh;
|
||||
margin-bottom: calc(var(--spacer) * 2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-top: 0;
|
||||
margin-bottom: calc(var(--spacer) / 2);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.title {
|
||||
font-size: var(--font-size-h1);
|
||||
}
|
||||
}
|
||||
|
||||
.header a,
|
||||
.description {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
24
src/pages/index.tsx
Normal file
24
src/pages/index.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
|
||||
import '../styles/global.css'
|
||||
|
||||
import Add from '../components/Add'
|
||||
import { title, description } from '../../site.config'
|
||||
import styles from './index.module.css'
|
||||
|
||||
import Layout from '../Layout'
|
||||
import Info from '../components/Info'
|
||||
|
||||
const Home = () => (
|
||||
<Layout>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
<p className={styles.description}>{description}</p>
|
||||
</header>
|
||||
|
||||
<Add />
|
||||
<Info />
|
||||
</Layout>
|
||||
)
|
||||
|
||||
export default Home
|
58
src/styles/_variables.css
Normal file
58
src/styles/_variables.css
Normal file
@ -0,0 +1,58 @@
|
||||
:root {
|
||||
--brand-main: #015565;
|
||||
--brand-cyan: #43a699;
|
||||
--brand-main-light: #88bec8;
|
||||
--brand-light: #e7eef4;
|
||||
|
||||
--brand-grey: #4e5d63;
|
||||
--brand-grey-light: #70858e;
|
||||
/* --brand-grey-dimmed: lighten($brand-grey, 50%); */
|
||||
|
||||
--font-family-base: 'ff-tisa-sans-web-pro', 'Trebuchet MS', 'Helvetica Neue',
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-family-headings: 'brandon-grotesque', 'Avenir Next', 'Helvetica Neue',
|
||||
Helvetica, Arial, sans-serif;
|
||||
--font-family-monospace: 'Fira Code', 'Fira Mono', Menlo, Monaco, Consolas,
|
||||
'Courier New', monospace;
|
||||
|
||||
--font-size-root: 18px;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-large: 1.15rem;
|
||||
--font-size-small: 0.85rem;
|
||||
--font-size-mini: 0.7rem;
|
||||
|
||||
--font-size-h1: 2.5rem;
|
||||
--font-size-h2: 2rem;
|
||||
--font-size-h3: 1.5rem;
|
||||
--font-size-h4: 1.2rem;
|
||||
--font-size-h5: 1.1rem;
|
||||
|
||||
--font-weight-base: 400;
|
||||
--font-weight-bold: 700;
|
||||
--line-height: 1.6;
|
||||
|
||||
--font-weight-headings: 500;
|
||||
--line-height-headings: 1.1;
|
||||
|
||||
--spacer: calc(var(--font-size-base) * var(--line-height));
|
||||
--border-radius: 0.3rem;
|
||||
|
||||
--screen-xs: 30em;
|
||||
--screen-sm: 40.625em;
|
||||
--screen-md: 60em;
|
||||
--screen-lg: 87.5em;
|
||||
|
||||
--easing: cubic-bezier(0.75, 0, 0.08, 1);
|
||||
|
||||
--body-background-color: var(--brand-light);
|
||||
--color-text: var(--brand-grey);
|
||||
--color-headings: var(--brand-main);
|
||||
--selection-background-color: var(--brand-main);
|
||||
}
|
||||
|
||||
:global(.dark) {
|
||||
--body-background-color: #1d2224;
|
||||
--color-text: var(--brand-grey-light);
|
||||
--color-headings: var(--brand-main-light);
|
||||
--selection-background: var(--brand-main-light);
|
||||
}
|
132
src/styles/global.css
Normal file
132
src/styles/global.css
Normal file
@ -0,0 +1,132 @@
|
||||
@import '_variables.css';
|
||||
@import url('https://use.typekit.net/msu4qap.css');
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-size-root);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: var(--font-family-base);
|
||||
font-weight: var(--font-weight-base);
|
||||
line-height: var(--line-height);
|
||||
text-rendering: optimizeLegibility;
|
||||
letter-spacing: -0.01em;
|
||||
font-feature-settings: 'liga', 'kern';
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
transition: background 0.4s var(--easing);
|
||||
background: var(--body-background-color);
|
||||
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: var(--brand-cyan);
|
||||
transition: 0.2s ease-out var(--easing);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
margin-bottom: var(--spacer);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
color: var(--color-headings);
|
||||
font-family: var(--font-family-headings);
|
||||
line-height: var(--line-height-headings);
|
||||
font-weight: var(--font-weight-headings);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-h1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-h3);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--font-size-h4);
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--font-size-h5);
|
||||
}
|
||||
|
||||
figure,
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
audio,
|
||||
embed,
|
||||
canvas,
|
||||
picture {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: calc(var(--spacer) / var(--line-height));
|
||||
padding-left: 0;
|
||||
list-style: none;
|
||||
padding-left: calc(var(--spacer) / var(--line-height));
|
||||
}
|
||||
|
||||
ul li {
|
||||
position: relative;
|
||||
display: block;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul li + li {
|
||||
margin-top: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
ul li:before {
|
||||
content: ' \2015';
|
||||
top: -1px;
|
||||
position: absolute;
|
||||
left: -1rem;
|
||||
color: var(--brand-grey);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--selection-background-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-background-color);
|
||||
color: #fff;
|
||||
}
|
12
src/utils.ts
Normal file
12
src/utils.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export async function pingUrl(url: string) {
|
||||
try {
|
||||
const response = await axios(url, { timeout: 5000 })
|
||||
if (!response || response.status !== 200) return false
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
return false
|
||||
}
|
||||
}
|
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"jsx": "preserve",
|
||||
"lib": ["dom", "es2017"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"noEmit": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"preserveConstEnums": true,
|
||||
"removeComments": false,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.mdx"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user