initial commit

This commit is contained in:
Matthias Kretschmann 2019-10-20 01:40:55 +02:00
commit 7de52e202d
Signed by: m
GPG Key ID: 606EEEF3C479A91F
36 changed files with 1156 additions and 0 deletions

36
.eslintrc Normal file
View 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
View File

@ -0,0 +1,7 @@
node_modules
npm-debug.log
.DS_Store
/.next/
/out/
/build
package-lock.json

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"tabWidth": 2
}

20
.travis.yml Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

35
next.config.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
)
}

View 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
View 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>
)
}

View 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);
}

View 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>
)
}

View 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
View 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>
)
}

View 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
View 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>
)
}

View 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);
}
}

View 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

View 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
View 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'}
/>
)
}

View 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;
}

View 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>
)
}

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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"]
}