feat: improve upload flow (#240)

* feat: separate flow for folder and file uploads

* feat: add basic index document detection

* feat(wip): separate preview step

* fix: fix kb and mb units

* feat: add post upload summary, add some styling

* feat: upload flow

* fix: change element order and add conditional rendering

* refactor: remove unused variables for now

* fix: put back stamp creation to stamp page

* refactor: rework postage stamps and grid

* feat: add website and folder icons

* feat: add asset preview to download flow, add file icon

* feat: add basic design to postage stamp selection dialog

* feat: add web icon, shorten stamp in preview

* feat: extract swarm hash in download flow

* fix: extract swarmbutton and solve icon hover and focus color

* fix: always show buy button on stamp page

* refactor: downgrade

* refactor: speed up icon transition

* style: improve download buttons

* style: change [back to upload] icon

* style: add spacing before swarm gateway text

* style: post upload summary spacing

* refactor: drop verticalspacing and use box

* refactor: merge icons to one component

* refactor: use conditions instead of weird assignment

* docs: explain filter(x => x)

* refactor: generalize capacity

* refactor: avoid passing arrow functions

* refactor: get rid of PaperGridContainer and Container

* fix: fix hover color for postage stamps

* feat: add disabled and loading state to buttons

* fix: make drag and drop work for websites

* feat: handle folders and non existing hashes

* fix: provide empty default value to select to avoid console warning

* style: remove body2 font variants

* fix: remove typo

* feat: disable folder upload, add website upload

* fix: disable showPreviews to avoid flickering

* feat(temp): remove folder upload

* fix: remove stuck focus on buttons even after rendering different buttons

* style: merge hover and focus styles, fix safari text wrap issue

* style: remove dropbox outline in safari
This commit is contained in:
Cafe137
2021-11-25 09:54:03 +01:00
committed by GitHub
parent 82cf6d9c01
commit 635621b04a
31 changed files with 1187 additions and 183 deletions
+5 -3
View File
@@ -6,7 +6,7 @@ import { ReactElement } from 'react'
const LIMIT = 100_000_000 // 100 megabytes const LIMIT = 100_000_000 // 100 megabytes
interface Props { interface Props {
file: File files: File[]
} }
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
@@ -22,14 +22,16 @@ const useStyles = makeStyles((theme: Theme) =>
export default function UploadSizeAlert(props: Props): ReactElement | null { export default function UploadSizeAlert(props: Props): ReactElement | null {
const classes = useStyles() const classes = useStyles()
const aboveLimit = props.file.size >= LIMIT const totalSize = props.files.reduce((previous, current) => previous + current.size, 0)
const aboveLimit = totalSize >= LIMIT
return ( return (
<Collapse in={aboveLimit}> <Collapse in={aboveLimit}>
<div className={classes.root}> <div className={classes.root}>
<Alert severity="warning"> <Alert severity="warning">
<AlertTitle>Warning</AlertTitle> <AlertTitle>Warning</AlertTitle>
The file you are trying to upload is above the recommended size. The chunks may not be synchronised properly The files you are trying to upload are above the recommended size. The chunks may not be synchronised properly
over the network. over the network.
</Alert> </Alert>
</div> </div>
+22
View File
@@ -0,0 +1,22 @@
import { ReactElement } from 'react'
interface Props {
width: string
usage: number
}
export function Capacity({ width, usage }: Props): ReactElement {
const integerUsage = Math.round(usage * 100)
const used = integerUsage + '%'
const free = 100 - 2 - integerUsage + '%'
return (
<div style={{ display: 'flex', alignItems: 'center', height: '100%', width }}>
<div style={{ display: 'flex', height: '4px', width: '100%' }}>
<div style={{ width: used, background: '#dd7200' }} />
<div style={{ width: '2%' }} />
<div style={{ width: free, background: '#c9c9c9' }} />
</div>
</div>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { Collapse, ListItem } from '@material-ui/core'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { ExpandLess, ExpandMore } from '@material-ui/icons'
import { ReactElement, ReactNode, useState } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: '100%',
padding: 0,
margin: 0,
marginTop: theme.spacing(4),
'&:first-child': {
marginTop: 0,
},
},
rootLevel1: { marginTop: theme.spacing(1) },
rootLevel2: { marginTop: theme.spacing(0.5) },
header: {
backgroundColor: theme.palette.background.paper,
},
contentLevel0: {
marginTop: theme.spacing(1),
},
contentLevel12: {
marginTop: theme.spacing(0.25),
},
infoText: {
color: '#c9c9c9',
},
}),
)
interface Props {
children: ReactNode
expandable: ReactNode
defaultOpen?: boolean
}
export default function ExpandableElement({ children, expandable, defaultOpen }: Props): ReactElement | null {
const classes = useStyles()
const [open, setOpen] = useState<boolean>(Boolean(defaultOpen))
const handleClick = () => {
setOpen(!open)
}
return (
<div className={`${classes.root} ${classes.rootLevel2}`}>
<ListItem button onClick={handleClick} className={classes.header}>
{children}
{open ? <ExpandLess /> : <ExpandMore />}
</ListItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<div className={classes.contentLevel12}>{expandable}</div>
</Collapse>
</div>
)
}
+10 -7
View File
@@ -1,6 +1,6 @@
import { ReactElement, ReactNode } from 'react'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import { Grid } from '@material-ui/core' import { Grid } from '@material-ui/core'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { ReactElement, ReactNode } from 'react'
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@@ -22,11 +22,14 @@ export default function ExpandableListItemActions({ children }: Props): ReactEle
if (Array.isArray(children)) { if (Array.isArray(children)) {
return ( return (
<Grid container direction="row"> <Grid container direction="row">
{children.map((a, i) => ( {children
<Grid key={i} className={classes.action}> // Exclude falsy values to allow conditional rendering
{a} .filter(x => x)
</Grid> .map((a, i) => (
))} <Grid key={i} className={classes.action}>
{a}
</Grid>
))}
</Grid> </Grid>
) )
} }
+10 -5
View File
@@ -1,9 +1,8 @@
import { ReactElement, ChangeEvent, useState } from 'react' import { Button, Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import Collapse from '@material-ui/core/Collapse' import Collapse from '@material-ui/core/Collapse'
import { ListItem, Typography, Grid, IconButton, InputBase, Button } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { Edit, Minus, RotateCcw, Check } from 'react-feather' import { ChangeEvent, ReactElement, useState } from 'react'
import { Check, Edit, Minus, RotateCcw } from 'react-feather'
import ExpandableListItemActions from './ExpandableListItemActions' import ExpandableListItemActions from './ExpandableListItemActions'
import ExpandableListItemNote from './ExpandableListItemNote' import ExpandableListItemNote from './ExpandableListItemNote'
@@ -55,6 +54,7 @@ interface Props {
confirmLabelDisabled?: boolean confirmLabelDisabled?: boolean
onChange?: (value: string) => void onChange?: (value: string) => void
onConfirm: (value: string) => void onConfirm: (value: string) => void
mapperFn?: (value: string) => string
} }
export default function ExpandableListItemKey({ export default function ExpandableListItemKey({
@@ -67,12 +67,17 @@ export default function ExpandableListItemKey({
expandedOnly, expandedOnly,
helperText, helperText,
placeholder, placeholder,
mapperFn,
}: Props): ReactElement | null { }: Props): ReactElement | null {
const classes = useStyles() const classes = useStyles()
const [open, setOpen] = useState(Boolean(expandedOnly)) const [open, setOpen] = useState(Boolean(expandedOnly))
const [inputValue, setInputValue] = useState<string>(value || '') const [inputValue, setInputValue] = useState<string>(value || '')
const toggleOpen = () => setOpen(!open) const toggleOpen = () => setOpen(!open)
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
if (mapperFn) {
e.target.value = mapperFn(e.target.value)
}
setInputValue(e.target.value) setInputValue(e.target.value)
if (onChange) onChange(e.target.value) if (onChange) onChange(e.target.value)
+4 -4
View File
@@ -1,9 +1,9 @@
import { ReactElement, useState } from 'react' import { Grid, IconButton, ListItem, Tooltip, Typography } from '@material-ui/core'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import Collapse from '@material-ui/core/Collapse' import Collapse from '@material-ui/core/Collapse'
import { ListItem, Typography, Grid, IconButton, Tooltip } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { Eye, Minus } from 'react-feather' import { ReactElement, useState } from 'react'
import { CopyToClipboard } from 'react-copy-to-clipboard' import { CopyToClipboard } from 'react-copy-to-clipboard'
import { Eye, Minus } from 'react-feather'
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
+81
View File
@@ -0,0 +1,81 @@
import { Grid, IconButton, ListItem, Tooltip, Typography } from '@material-ui/core'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { OpenInNewSharp } from '@material-ui/icons'
import { ReactElement, useState } from 'react'
import CopyToClipboard from 'react-copy-to-clipboard'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
header: {
backgroundColor: theme.palette.background.paper,
marginBottom: theme.spacing(0.25),
borderLeft: `${theme.spacing(0.25)}px solid rgba(0,0,0,0)`,
wordBreak: 'break-word',
},
headerOpen: {
borderLeft: `${theme.spacing(0.25)}px solid ${theme.palette.primary.main}`,
},
openLinkIcon: {
cursor: 'pointer',
padding: theme.spacing(1),
borderRadius: 0,
'&:hover': {
backgroundColor: '#fcf2e8',
color: theme.palette.primary.main,
},
},
content: {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
keyMargin: {
marginRight: theme.spacing(1),
},
copyValue: {
cursor: 'pointer',
padding: theme.spacing(1),
borderRadius: 0,
'&:hover': {
backgroundColor: '#fcf2e8',
color: theme.palette.primary.main,
},
},
}),
)
interface Props {
label: string
value: string
}
export default function ExpandableListItemLink({ label, value }: Props): ReactElement | null {
const classes = useStyles()
const [copied, setCopied] = useState(false)
const tooltipClickHandler = () => setCopied(true)
const tooltipCloseHandler = () => setCopied(false)
return (
<ListItem className={classes.header}>
<Grid container direction="column" justifyContent="space-between" alignItems="stretch">
<Grid container direction="row" justifyContent="space-between" alignItems="center">
{label && <Typography variant="body1">{label}</Typography>}
<Typography variant="body2">
<div>
<span className={classes.copyValue}>
<Tooltip title={copied ? 'Copied' : 'Copy'} placement="top" arrow onClose={tooltipCloseHandler}>
<CopyToClipboard text={value}>
<span onClick={tooltipClickHandler}>{value.slice(0, 19)}...</span>
</CopyToClipboard>
</Tooltip>
</span>
<IconButton size="small" className={classes.openLinkIcon}>
<OpenInNewSharp onClick={() => window.open(value)} strokeWidth={1} />
</IconButton>
</div>
</Typography>
</Grid>
</Grid>
</ListItem>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { createStyles, makeStyles } from '@material-ui/core'
import { ReactElement } from 'react'
const useStyles = makeStyles(() =>
createStyles({
image: {
width: '100%',
height: '100%',
objectFit: 'cover',
},
}),
)
interface Props {
alt: string
src: string | undefined
maxHeight?: string
maxWidth?: string
}
export function FitImage(props: Props): ReactElement {
const classes = useStyles()
const inlineStyles: Record<string, string> = {}
props.maxHeight && (inlineStyles.maxHeight = props.maxHeight)
props.maxWidth && (inlineStyles.maxWidth = props.maxWidth)
return <img className={classes.image} alt={props.alt} src={props.src} style={inlineStyles} />
}
+31
View File
@@ -0,0 +1,31 @@
import { createStyles, makeStyles } from '@material-ui/core'
import { ReactElement } from 'react'
interface Props {
children: ReactElement | ReactElement[]
}
const useStyles = makeStyles(() =>
createStyles({
wrapper: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '175px',
height: '175px',
background: `repeating-linear-gradient(
45deg,
#efefef,
#efefef 4px,
#ffffff 4px,
#ffffff 8px
)`,
},
}),
)
export function StripedWrapper({ children }: Props): ReactElement {
const classes = useStyles()
return <div className={classes.wrapper}>{children}</div>
}
+66
View File
@@ -0,0 +1,66 @@
import { Button, CircularProgress, createStyles, makeStyles } from '@material-ui/core'
import React, { ReactElement } from 'react'
import { IconProps } from 'react-feather'
interface Props {
onClick: () => void
iconType: React.ComponentType<IconProps>
children: string
className?: string
disabled?: boolean
loading?: boolean
}
const useStyles = makeStyles(() =>
createStyles({
button: {
position: 'relative',
whiteSpace: 'nowrap',
'&:hover, &:focus': {
'& svg': {
stroke: '#fff',
transition: '0.1s',
},
},
},
spinnerWrapper: {
position: 'absolute',
left: '50%',
top: '50%',
width: '40px',
height: '40px',
transform: 'translate(-50%, -50%)',
},
}),
)
export function SwarmButton({ children, onClick, iconType, className, disabled, loading }: Props): ReactElement {
const classes = useStyles()
const icon = React.createElement(iconType, {
size: '1.25rem',
color: disabled ? 'rgba(0, 0, 0, 0.26)' : '#dd7700',
})
const classNames = className ? [className, classes.button].join(' ') : classes.button
return (
<Button
className={classNames}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick()
event.currentTarget.blur()
}}
variant="contained"
startIcon={icon}
disabled={disabled}
>
{children}
{loading && (
<div className={classes.spinnerWrapper}>
<CircularProgress />
</div>
)}
</Button>
)
}
+3 -4
View File
@@ -1,16 +1,15 @@
import { BigNumber } from 'bignumber.js'
import { ReactElement, useContext } from 'react' import { ReactElement, useContext } from 'react'
import { Upload } from 'react-feather' import { Upload } from 'react-feather'
import { Context as SettingsContext } from '../providers/Settings'
import WithdrawDepositModal from '../components/WithdrawDepositModal' import WithdrawDepositModal from '../components/WithdrawDepositModal'
import { BigNumber } from 'bignumber.js' import { Context as SettingsContext } from '../providers/Settings'
export default function WithdrawModal(): ReactElement { export default function WithdrawModal(): ReactElement {
const { beeDebugApi } = useContext(SettingsContext) const { beeDebugApi } = useContext(SettingsContext)
return ( return (
<WithdrawDepositModal <WithdrawDepositModal
successMessage="Successful withdrawl." successMessage="Successful withdrawal."
errorMessage="Error with withdrawing." errorMessage="Error with withdrawing."
dialogMessage="Specify the amount of BZZ you would like to withdraw from your node." dialogMessage="Specify the amount of BZZ you would like to withdraw from your node."
label="Withdraw" label="Withdraw"
+10
View File
@@ -0,0 +1,10 @@
import { ReactElement } from 'react'
import { StripedWrapper } from '../../components/StripedWrapper'
interface Props {
icon: ReactElement
}
export function AssetIcon({ icon }: Props): ReactElement {
return <StripedWrapper>{icon}</StripedWrapper>
}
+95
View File
@@ -0,0 +1,95 @@
import { Box, Grid, Typography } from '@material-ui/core'
import { Web } from '@material-ui/icons'
import { ReactElement, useEffect, useState } from 'react'
import { File, Folder } from 'react-feather'
import { FitImage } from '../../components/FitImage'
import { detectIndexHtml, getHumanReadableFileSize } from '../../utils/file'
import { SwarmFile } from '../../utils/SwarmFile'
import { AssetIcon } from './AssetIcon'
interface Props {
files: SwarmFile[]
}
export function AssetPreview({ files }: Props): ReactElement {
const [previewComponent, setPreviewComponent] = useState<ReactElement | undefined>(undefined)
const [previewUri, setPreviewUri] = useState<string | undefined>(undefined)
useEffect(() => {
if (files.length === 1) {
// single image
if (files[0].type.startsWith('image/')) {
files[0].arrayBuffer().then(value => {
const blob = new Blob([value])
setPreviewUri(URL.createObjectURL(blob))
})
// single non-image
} else {
setPreviewUri(undefined)
setPreviewComponent(<AssetIcon icon={<File />} />)
}
// collection
} else if (detectIndexHtml(files)) {
setPreviewUri(undefined)
setPreviewComponent(<AssetIcon icon={<Web />} />)
} else {
setPreviewUri(undefined)
setPreviewComponent(<AssetIcon icon={<Folder />} />)
}
}, [files])
const getPrimaryText = () => {
if (files.length === 1) {
return 'Filename: ' + files[0].name
}
return 'Folder name: ' + files[0].path.split('/')[0]
}
const getKind = () => {
if (files.length === 1) {
return files[0].type
}
if (detectIndexHtml(files)) {
return 'Website'
}
return 'Folder'
}
const isFolder = () => ['Folder', 'Website'].includes(getKind())
const getSize = () => {
const bytes = files.reduce((total, item) => total + item.size, 0)
return getHumanReadableFileSize(bytes)
}
return (
<Box mb={4}>
<Box bgcolor="background.paper">
<Grid container direction="row">
{previewComponent ? (
previewComponent
) : (
<FitImage maxWidth="250px" maxHeight="175px" alt="Upload Preview" src={previewUri} />
)}
<Box p={2}>
<Typography>{getPrimaryText()}</Typography>
<Typography>Kind: {getKind()}</Typography>
<Typography>Size: {getSize()}</Typography>
</Box>
</Grid>
</Box>
{isFolder() && (
<Box mt={0.25} p={2} bgcolor="background.paper">
<Grid container justifyContent="space-between" alignItems="center" direction="row">
<Typography variant="subtitle2">Folder content</Typography>
<Typography variant="subtitle2">{files.length} items</Typography>
</Grid>
</Box>
)}
</Box>
)
}
+69 -6
View File
@@ -1,28 +1,91 @@
import { ReactElement, useState, useContext } from 'react'
import { Context as SettingsContext } from '../../providers/Settings'
import ExpandableListItemInput from '../../components/ExpandableListItemInput'
import { Utils } from '@ethersphere/bee-js' import { Utils } from '@ethersphere/bee-js'
import { Box } from '@material-ui/core'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useState } from 'react'
import ExpandableListItemInput from '../../components/ExpandableListItemInput'
import { Context as SettingsContext } from '../../providers/Settings'
import { extractSwarmHash } from '../../utils'
import { convertBeeFileToBrowserFile } from '../../utils/file'
import { SwarmFile } from '../../utils/SwarmFile'
import { AssetPreview } from './AssetPreview'
import { DownloadActionBar } from './DownloadActionBar'
export default function Files(): ReactElement { export default function Files(): ReactElement {
const { apiUrl } = useContext(SettingsContext) const { apiUrl, beeApi } = useContext(SettingsContext)
const [reference, setReference] = useState('')
const [referenceError, setReferenceError] = useState<string | undefined>(undefined) const [referenceError, setReferenceError] = useState<string | undefined>(undefined)
const [downloadedFile, setDownloadedFile] = useState<Partial<File> | null>(null)
const { enqueueSnackbar } = useSnackbar()
const validateChange = (value: string) => { const validateChange = (value: string) => {
if (Utils.isHexString(value, 64) || Utils.isHexString(value, 128)) setReferenceError(undefined) if (Utils.isHexString(value, 64) || Utils.isHexString(value, 128)) setReferenceError(undefined)
else setReferenceError('Incorrect format of swarm hash. Expected 64 or 128 hexstring characters.') else setReferenceError('Incorrect format of swarm hash. Expected 64 or 128 hexstring characters.')
} }
function onDownload() {
window.open(`${apiUrl}/bzz/${reference}/`, '_blank')
}
async function onSwarmIdentifier(identifier: string) {
if (!beeApi) {
return
}
setReference(identifier)
try {
const response = await beeApi.downloadFile(identifier)
setDownloadedFile(convertBeeFileToBrowserFile(response))
} catch (error: unknown) {
let message = typeof error === 'object' && error !== null && Reflect.get(error, 'message')
if (message.includes('path address not found')) {
message = 'The specified hash does not have an index document set.'
}
if (message.includes('Not Found: Not Found')) {
message = 'The specified hash was not found.'
}
enqueueSnackbar(<span>Error: {message || 'Unknown'}</span>, { variant: 'error' })
}
}
if (downloadedFile) {
return (
<>
<Box mb={4}>
<AssetPreview files={[new SwarmFile(downloadedFile as File)]} />
</Box>
<DownloadActionBar onCancel={() => setDownloadedFile(null)} onDownload={onDownload} />
</>
)
}
function recognizeSwarmHash(value: string) {
if (value.length < 64) {
return value
}
const hash = extractSwarmHash(value)
if (hash) {
return hash
}
return value
}
return ( return (
<ExpandableListItemInput <ExpandableListItemInput
label="Swarm Hash" label="Swarm Hash"
onConfirm={value => window.open(`${apiUrl}/bzz/${value}`, '_blank')} onConfirm={value => onSwarmIdentifier(value)}
onChange={validateChange} onChange={validateChange}
helperText={referenceError} helperText={referenceError}
confirmLabel={'Download'} confirmLabel={'Search'}
confirmLabelDisabled={Boolean(referenceError)} confirmLabelDisabled={Boolean(referenceError)}
placeholder="e.g. 31fb0362b1a42536134c86bc58b97ac0244e5c6630beec3e27c2d1cecb38c605" placeholder="e.g. 31fb0362b1a42536134c86bc58b97ac0244e5c6630beec3e27c2d1cecb38c605"
expandedOnly expandedOnly
mapperFn={value => recognizeSwarmHash(value)}
/> />
) )
} }
+24
View File
@@ -0,0 +1,24 @@
import { Button } from '@material-ui/core'
import { Clear } from '@material-ui/icons'
import { ReactElement } from 'react'
import { Download } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
interface Props {
onDownload: () => void
onCancel: () => void
}
export function DownloadActionBar({ onDownload, onCancel }: Props): ReactElement {
return (
<ExpandableListItemActions>
<SwarmButton onClick={onDownload} iconType={Download}>
Download This File
</SwarmButton>
<Button onClick={onCancel} variant="contained" startIcon={<Clear />}>
Cancel
</Button>
</ExpandableListItemActions>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { Box, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
import { CornerUpLeft } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import ExpandableListItemLink from '../../components/ExpandableListItemLink'
import { SwarmButton } from '../../components/SwarmButton'
interface Props {
uploadReference: string
onUploadNewClick: () => void
}
export function PostUploadSummary({ uploadReference, onUploadNewClick }: Props): ReactElement {
return (
<>
<Box mb={4}>
<ExpandableListItemKey label="Swarm hash" value={uploadReference} />
<ExpandableListItemLink
label="Share on Swarm Gateway"
value={`https://gateway.ethswarm.org/access/${uploadReference}`}
/>
</Box>
<Box mb={2}>
<ExpandableListItemActions>
<SwarmButton onClick={onUploadNewClick} iconType={CornerUpLeft}>
Back to Upload
</SwarmButton>
</ExpandableListItemActions>
</Box>
<Typography>
The Swarm Gateway is graciously provided by the Swarm Foundation. This service is under development and provided
for testing purposes only. Learn more at{' '}
<a href="https://gateway.ethswarm.org/">https://gateway.ethswarm.org/</a>.
</Typography>
</>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Box, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
import { EnrichedPostageBatch } from '../../providers/Stamps'
import { PostageStamp } from '../stamps/PostageStamp'
interface Props {
stamp: EnrichedPostageBatch
}
export function StampPreview({ stamp }: Props): ReactElement {
return (
<Box mb={4}>
<Box mb={0.25} p={2} bgcolor="background.paper">
<Typography variant="subtitle2">Associated postage stamp:</Typography>
</Box>
<Box bgcolor="background.paper">
<PostageStamp stamp={stamp} shorten={true} />
</Box>
</Box>
)
}
+58 -113
View File
@@ -1,19 +1,17 @@
import { Button, CircularProgress, Container, Avatar, Chip, Typography } from '@material-ui/core' import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
import { DropzoneArea } from 'material-ui-dropzone'
import { useSnackbar } from 'notistack' import { useSnackbar } from 'notistack'
import { RotateCcw, Check } from 'react-feather'
import { ReactElement, useContext, useEffect, useState } from 'react' import { ReactElement, useContext, useEffect, useState } from 'react'
import UploadSizeAlert from '../../components/AlertUploadSize'
import ClipboardCopy from '../../components/ClipboardCopy'
import { Context, EnrichedPostageBatch } from '../../providers/Stamps'
import { Context as SettingsContext } from '../../providers/Settings' import { Context as SettingsContext } from '../../providers/Settings'
import CreatePostageStamp from '../stamps/CreatePostageStampModal' import { Context, EnrichedPostageBatch } from '../../providers/Stamps'
import SelectStamp from './SelectStamp' import { detectIndexHtml } from '../../utils/file'
import ExpandableListItem from '../../components/ExpandableListItem' import { SwarmFile } from '../../utils/SwarmFile'
import ExpandableListItemKey from '../../components/ExpandableListItemKey' import { CreatePostageStampModal } from '../stamps/CreatePostageStampModal'
import ExpandableListItemNote from '../../components/ExpandableListItemNote' import { SelectPostageStampModal } from '../stamps/SelectPostageStampModal'
import ExpandableListItemActions from '../../components/ExpandableListItemActions' import { AssetPreview } from './AssetPreview'
import { PostUploadSummary } from './PostUploadSummary'
import { StampPreview } from './StampPreview'
import { UploadActionBar } from './UploadActionBar'
import { UploadArea } from './UploadArea'
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@@ -27,13 +25,14 @@ const MAX_FILE_SIZE = 1_000_000_000 // 1 gigabyte
export default function Files(): ReactElement { export default function Files(): ReactElement {
const classes = useStyles() const classes = useStyles()
const [dropzoneKey, setDropzoneKey] = useState(0) const [dropzoneKey, setDropzoneKey] = useState(0)
const [file, setFile] = useState<File | null>(null) const [files, setFiles] = useState<SwarmFile[]>([])
const [uploadReference, setUploadReference] = useState('') const [uploadReference, setUploadReference] = useState('')
const [isUploadingFile, setIsUploadingFile] = useState(false) const [isBuyingStamp, setBuyingStamp] = useState(false)
const [isSelectingStamp, setSelectingStamp] = useState(false)
const [stamp, setStamp] = useState<EnrichedPostageBatch | null>(null)
const [isUploading, setUploading] = useState(false)
const [selectedStamp, setSelectedStamp] = useState<EnrichedPostageBatch | null>(null) const { stamps, refresh } = useContext(Context)
const { isLoading, error, stamps, refresh } = useContext(Context)
const { beeApi } = useContext(SettingsContext) const { beeApi } = useContext(SettingsContext)
const { enqueueSnackbar } = useSnackbar() const { enqueueSnackbar } = useSnackbar()
@@ -41,123 +40,69 @@ export default function Files(): ReactElement {
refresh() refresh()
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []) // eslint-disable-line react-hooks/exhaustive-deps
// Choose a postage stamp that has the lowest usage const uploadFiles = () => {
useEffect(() => { if (!beeApi || !files.length || !stamp) {
if (!selectedStamp && stamps && stamps.length > 0) { return
const stamp = stamps.reduce((prev, curr) => {
if (curr.usage < prev.usage) return curr
return prev
}, stamps[0])
setSelectedStamp(stamp)
} }
}, [isLoading, error, stamps, selectedStamp])
const uploadFile = () => { const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(files) || undefined
if (file === null || selectedStamp === null) return
if (!beeApi) return setUploading(true)
setIsUploadingFile(true)
beeApi beeApi
.uploadFile(selectedStamp.batchID, file) .uploadFiles(stamp.batchID, files as unknown as File[], { indexDocument })
.then(hash => setUploadReference(hash.reference)) .then(hash => setUploadReference(hash.reference))
.catch(e => enqueueSnackbar(`Error uploading: ${e.message}`, { variant: 'error' })) .catch(e => enqueueSnackbar(`Error uploading: ${e.message}`, { variant: 'error' }))
.finally(() => setIsUploadingFile(false)) .finally(() => setUploading(false))
}
const reset = () => {
setFiles([])
setStamp(null)
setUploading(false)
} }
const uploadNew = () => { const uploadNew = () => {
setTimeout(() => { setTimeout(() => {
setFile(null) reset()
setDropzoneKey(dropzoneKey + 1) setDropzoneKey(dropzoneKey + 1)
setUploadReference('') setUploadReference('')
}, 0) }, 0)
} }
const handleChange = (files?: File[]) => {
setUploadReference('')
if (files) {
setFile(files[0])
}
}
return ( return (
<> <>
<DropzoneArea {files.length ? (
key={'dropzone-' + dropzoneKey} <AssetPreview files={files} />
onChange={handleChange} ) : (
filesLimit={1} <UploadArea maximumSizeInBytes={MAX_FILE_SIZE} setFiles={setFiles} />
maxFileSize={MAX_FILE_SIZE} )}
/> {stamp !== null && !uploadReference ? <StampPreview stamp={stamp} /> : null}
{files.length && !uploadReference ? (
<UploadActionBar
canSelectStamp={stamps !== null && stamps.length > 0}
hasSelectedStamp={stamp !== null}
onCancel={reset}
onBuy={() => setBuyingStamp(true)}
onSelect={() => setSelectingStamp(true)}
onUpload={uploadFiles}
onClearStamp={() => setStamp(null)}
isUploading={isUploading}
/>
) : null}
<div className={classes.content}> <div className={classes.content}>
{/* We have file and can upload display stamp selection */}
{file && !isUploadingFile && !uploadReference && (
<>
<ExpandableListItemNote>
To upload this file to your node, you need a postage stamp. You can buy a new one or you can use an
existing stamp (providing its sufficient for this file).
</ExpandableListItemNote>
{selectedStamp && (
<ExpandableListItem
label={
<>
Upload with Postage Stamp{' '}
<Chip
avatar={<Avatar>{selectedStamp.usageText}</Avatar>}
label={<Typography variant="body2">{selectedStamp.batchID.substr(0, 8)}[]</Typography>}
deleteIcon={<ClipboardCopy value={selectedStamp.batchID} />}
onDelete={() => {} /* eslint-disable-line*/}
variant="outlined"
/>
</>
}
value={<SelectStamp stamps={stamps} selectedStamp={selectedStamp} setSelected={setSelectedStamp} />}
/>
)}
{!selectedStamp && (
<ExpandableListItemActions>
<CreatePostageStamp />
</ExpandableListItemActions>
)}
</>
)}
{/* We have file and can upload display upload button */}
{file && !uploadReference && (
<>
<ExpandableListItemActions>
<Button
variant="contained"
disabled={!file && isUploadingFile && !selectedStamp}
onClick={() => uploadFile()}
startIcon={<Check size="1rem" />}
>
Upload
</Button>
{isUploadingFile && (
<Container className={classes.loadingProgress}>
<CircularProgress />
</Container>
)}
</ExpandableListItemActions>
<UploadSizeAlert file={file} />
</>
)}
{/* File has already been uploaded */}
{uploadReference && ( {uploadReference && (
<> <PostUploadSummary onUploadNewClick={() => uploadNew()} uploadReference={uploadReference} />
<ExpandableListItemKey label="Swarm Reference" value={uploadReference} />
<ExpandableListItemActions>
<Button variant="contained" onClick={uploadNew} startIcon={<RotateCcw size="1rem" />}>
Upload New File
</Button>
</ExpandableListItemActions>
</>
)} )}
</div> </div>
{isBuyingStamp ? <CreatePostageStampModal onClose={() => setBuyingStamp(false)} /> : null}
{stamps && isSelectingStamp ? (
<SelectPostageStampModal
stamps={stamps}
onClose={() => setSelectingStamp(false)}
onSelect={stamp => setStamp(stamp)}
/>
) : null}
</> </>
) )
} }
+69
View File
@@ -0,0 +1,69 @@
import { Button, Typography } from '@material-ui/core'
import { Clear } from '@material-ui/icons'
import { ReactElement } from 'react'
import { Check, Layers, PlusSquare, RefreshCcw } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
interface Props {
canSelectStamp: boolean
hasSelectedStamp: boolean
onUpload: () => void
onBuy: () => void
onSelect: () => void
onCancel: () => void
onClearStamp: () => void
isUploading: boolean
}
export function UploadActionBar({
canSelectStamp,
hasSelectedStamp,
onUpload,
onBuy,
onSelect,
onCancel,
onClearStamp,
isUploading,
}: Props): ReactElement {
const showBuy = !hasSelectedStamp
const showSelect = canSelectStamp && !hasSelectedStamp
const showUpload = hasSelectedStamp
const showChange = canSelectStamp && hasSelectedStamp
return (
<>
<ExpandableListItemActions>
{showBuy ? (
<SwarmButton onClick={onBuy} iconType={PlusSquare}>
Buy New Postage Stamp
</SwarmButton>
) : null}
{showSelect ? (
<SwarmButton onClick={onSelect} iconType={Layers}>
Use Existing Postage Stamp
</SwarmButton>
) : null}
{showUpload ? (
<SwarmButton onClick={onUpload} iconType={Check} disabled={isUploading} loading={isUploading}>
Upload To Your Node
</SwarmButton>
) : null}
{showChange ? (
<SwarmButton onClick={onClearStamp} iconType={RefreshCcw} disabled={isUploading}>
Change Postage Stamp
</SwarmButton>
) : null}
<Button onClick={onCancel} variant="contained" startIcon={<Clear />}>
Cancel
</Button>
</ExpandableListItemActions>
{showSelect ? (
<Typography>
You need a postage stamp to upload. Please refer to the official Bee documentation to understand how postage
stamps work.
</Typography>
) : null}
</>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core'
import { DropzoneArea } from 'material-ui-dropzone'
import { useSnackbar } from 'notistack'
import { ReactElement } from 'react'
import { FilePlus } from 'react-feather'
import { SwarmButton } from '../../components/SwarmButton'
import { detectIndexHtml } from '../../utils/file'
import { SwarmFile } from '../../utils/SwarmFile'
interface Props {
setFiles: (files: SwarmFile[]) => void
maximumSizeInBytes: number
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
areaWrapper: { position: 'relative', marginBottom: theme.spacing(2) },
dropzone: {
background: theme.palette.background.default,
outline: 'none',
color: 'transparent',
zIndex: 1,
'& svg': {
opacity: 0,
},
},
buttonWrapper: {
top: '0',
left: '0',
position: 'absolute',
display: 'flex',
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
},
button: {
marginLeft: theme.spacing(0.5),
marginRight: theme.spacing(0.5),
zIndex: 2,
},
}),
)
export function UploadArea({ setFiles, maximumSizeInBytes }: Props): ReactElement {
const classes = useStyles()
const { enqueueSnackbar } = useSnackbar()
const getDropzoneInputDomElement = () => document.querySelector('.MuiDropzoneArea-root input') as HTMLInputElement
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const onUploadFolderClick = () => {
const element = getDropzoneInputDomElement()
if (element) {
element.setAttribute('directory', '')
element.setAttribute('webkitdirectory', '')
element.setAttribute('mozdirectory', '')
element.click()
}
}
const onUploadFileClick = () => {
const element = getDropzoneInputDomElement()
if (element) {
element.removeAttribute('directory')
element.removeAttribute('webkitdirectory')
element.removeAttribute('mozdirectory')
element.click()
}
}
const resetComponentOnAddingInvalidContent = (files: SwarmFile[]) => {
setFiles(files)
setTimeout(() => {
setFiles([])
}, 0)
}
const handleChange = (files?: File[]) => {
if (files) {
const swarmFiles = files.map(x => new SwarmFile(x))
const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(swarmFiles) || undefined
if (files.length && !indexDocument) {
enqueueSnackbar('To upload a website, there must be an index.html or index.htm in the root of the folder.', {
variant: 'error',
})
resetComponentOnAddingInvalidContent(swarmFiles)
return
}
setFiles(swarmFiles)
}
}
return (
<>
<div className={classes.areaWrapper}>
<DropzoneArea
dropzoneClass={classes.dropzone}
onChange={handleChange}
filesLimit={1}
maxFileSize={maximumSizeInBytes}
showPreviews={false}
/>
<div className={classes.buttonWrapper}>
<SwarmButton className={classes.button} onClick={onUploadFileClick} iconType={FilePlus}>
Add File
</SwarmButton>
</div>
</div>
<Typography>You can click the button above or simply drag and drop to add a file.</Typography>
</>
)
}
+11 -20
View File
@@ -47,16 +47,13 @@ const useStyles = makeStyles((theme: Theme) =>
) )
interface Props { interface Props {
label?: string onClose: () => void
} }
export default function FormDialog({ label }: Props): ReactElement { export function CreatePostageStampModal({ onClose }: Props): ReactElement {
const classes = useStyles() const classes = useStyles()
const [open, setOpen] = React.useState(false)
const { refresh } = useContext(Context) const { refresh } = useContext(Context)
const { beeDebugApi } = useContext(SettingsContext) const { beeDebugApi } = useContext(SettingsContext)
const handleClickOpen = () => setOpen(true)
const handleClose = () => setOpen(false)
const { enqueueSnackbar } = useSnackbar() const { enqueueSnackbar } = useSnackbar()
return ( return (
@@ -75,7 +72,7 @@ export default function FormDialog({ label }: Props): ReactElement {
await beeDebugApi.createPostageBatch(amount.toString(), depth, options) await beeDebugApi.createPostageBatch(amount.toString(), depth, options)
actions.resetForm() actions.resetForm()
await refresh() await refresh()
handleClose() onClose()
} catch (e) { } catch (e) {
enqueueSnackbar(`Error: ${(e as Error).message}`, { variant: 'error' }) enqueueSnackbar(`Error: ${(e as Error).message}`, { variant: 'error' })
actions.setSubmitting(false) actions.setSubmitting(false)
@@ -111,20 +108,9 @@ export default function FormDialog({ label }: Props): ReactElement {
> >
{({ submitForm, isValid, isSubmitting, values }) => ( {({ submitForm, isValid, isSubmitting, values }) => (
<Form> <Form>
<Button variant="contained" onClick={handleClickOpen}> <Dialog open={true} onClose={onClose} aria-labelledby="form-dialog-title">
{label || 'Buy Postage Stamp'} <DialogTitle id="form-dialog-title">Buy new postage stamp</DialogTitle>
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
</Button>
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Purchase new postage stamp</DialogTitle>
<DialogContent> <DialogContent>
<DialogContentText>
Provide the depth, amount and optionally the label of the postage stamp. Please refer to the{' '}
<a href="https://docs.ethswarm.org/docs/access-the-swarm/keep-your-data-alive" target="blank">
official bee docs
</a>{' '}
to understand these values.
</DialogContentText>
<Field <Field
component={TextField} component={TextField}
required required
@@ -138,7 +124,7 @@ export default function FormDialog({ label }: Props): ReactElement {
<Field component={TextField} name="label" label="Label" fullWidth className={classes.field} /> <Field component={TextField} name="label" label="Label" fullWidth className={classes.field} />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleClose} variant="contained"> <Button onClick={onClose} variant="contained">
Cancel Cancel
</Button> </Button>
<div className={classes.wrapper}> <div className={classes.wrapper}>
@@ -153,6 +139,11 @@ export default function FormDialog({ label }: Props): ReactElement {
</Button> </Button>
</div> </div>
</DialogActions> </DialogActions>
<DialogContent>
<DialogContentText>
Please refer to the official Bee documentation to understand these values.
</DialogContentText>
</DialogContent>
</Dialog> </Dialog>
</Form> </Form>
)} )}
+20
View File
@@ -0,0 +1,20 @@
import { Box, Grid, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
import { Capacity } from '../../components/Capacity'
import { EnrichedPostageBatch } from '../../providers/Stamps'
interface Props {
stamp: EnrichedPostageBatch
shorten?: boolean
}
export function PostageStamp({ stamp, shorten }: Props): ReactElement {
return (
<Box p={2} width="100%">
<Grid container justifyContent="space-between" alignItems="center" direction="row">
<Typography variant="subtitle2">{shorten ? stamp.batchID.slice(0, 8) : stamp.batchID}</Typography>
<Capacity width="100px" usage={stamp.usage} />
</Grid>
</Box>
)
}
@@ -0,0 +1,118 @@
import { Box, createStyles, FormControl, makeStyles, MenuItem, Select, Theme, Typography } from '@material-ui/core'
import Button from '@material-ui/core/Button'
import Dialog from '@material-ui/core/Dialog'
import DialogContent from '@material-ui/core/DialogContent'
import DialogTitle from '@material-ui/core/DialogTitle'
import { Check, Clear } from '@material-ui/icons'
import React, { ReactElement, useState } from 'react'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { EnrichedPostageBatch } from '../../providers/Stamps'
interface Props {
stamps: EnrichedPostageBatch[]
onSelect: (stamp: EnrichedPostageBatch) => void
onClose: () => void
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
dialog: {
background: theme.palette.background.default,
borderRadius: 0,
width: '100%',
maxWidth: '890px',
},
title: {
color: '#606060',
textAlign: 'center',
},
select: {
background: theme.palette.background.paper,
borderRadius: 0,
border: 0,
},
option: {
height: '52px',
},
hint: {
marginBottom: '16px',
},
}),
)
export function SelectPostageStampModal({ stamps, onSelect, onClose }: Props): ReactElement {
const [selectedStamp, setSelectedStamp] = useState<EnrichedPostageBatch | null>(null)
const classes = useStyles()
function onChange(stampId: string) {
const stamp = stamps.find(x => x.batchID === stampId)
if (stamp) {
setSelectedStamp(stamp)
}
}
function onFinish() {
if (selectedStamp) {
onSelect(selectedStamp)
onClose()
}
}
return (
<Dialog
open={true}
onClose={onClose}
aria-labelledby="form-dialog-title"
fullWidth
PaperProps={{ className: classes.dialog }}
>
<DialogTitle id="form-dialog-title" className={classes.title}>
Select postage stamp
</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<Select
onChange={event => onChange(event.target.value as string)}
fullWidth
variant="outlined"
className={classes.select}
defaultValue=""
>
{stamps.map(x => (
<MenuItem key={x.batchID} value={x.batchID} className={classes.option}>
{x.batchID.slice(0, 8)}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<Box mb={2}>
<DialogContent>
<ExpandableListItemActions>
<Button disabled={!selectedStamp} onClick={onFinish} variant="contained" startIcon={<Check />}>
Select
</Button>
<Button onClick={onClose} variant="contained" startIcon={<Clear />}>
Cancel
</Button>
</ExpandableListItemActions>
</DialogContent>
</Box>
<DialogContent>
<Typography className={classes.hint}>
Please refer to the{' '}
<a
href="https://docs.ethswarm.org/docs/access-the-swarm/keep-your-data-alive#purchase-a-batch-of-stamps"
target="_blank"
rel="noreferrer"
>
official Bee documentation
</a>{' '}
to understand these values.
</Typography>
</DialogContent>
</Dialog>
)
}
+10 -7
View File
@@ -1,8 +1,9 @@
import type { ReactElement } from 'react' import type { ReactElement } from 'react'
import { EnrichedPostageBatch } from '../../providers/Stamps' import ExpandableElement from '../../components/ExpandableElement'
import ExpandableList from '../../components/ExpandableList' import ExpandableList from '../../components/ExpandableList'
import ExpandableListItem from '../../components/ExpandableListItem'
import ExpandableListItemKey from '../../components/ExpandableListItemKey' import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import { EnrichedPostageBatch } from '../../providers/Stamps'
import { PostageStamp } from './PostageStamp'
interface Props { interface Props {
postageStamps: EnrichedPostageBatch[] | null postageStamps: EnrichedPostageBatch[] | null
@@ -13,11 +14,13 @@ function StampsTable({ postageStamps }: Props): ReactElement | null {
return ( return (
<ExpandableList label="Postage Stamps" defaultOpen> <ExpandableList label="Postage Stamps" defaultOpen>
{postageStamps.map(({ batchID, usageText }) => ( {postageStamps.map(stamp => (
<ExpandableList key={batchID} label={`${batchID.substr(0, 8)}[…]`} level={1} info={`${usageText} used`}> <ExpandableElement
<ExpandableListItemKey label="Batch ID" value={batchID} /> key={stamp.batchID}
<ExpandableListItem label="Usage" value={usageText} /> expandable={<ExpandableListItemKey label="Batch ID" value={stamp.batchID} />}
</ExpandableList> >
<PostageStamp stamp={stamp} shorten={true} />
</ExpandableElement>
))} ))}
</ExpandableList> </ExpandableList>
) )
+16 -9
View File
@@ -1,13 +1,13 @@
import { ReactElement, useContext, useEffect } from 'react' import { CircularProgress, Container } from '@material-ui/core'
import { createStyles, makeStyles } from '@material-ui/core/styles' import { createStyles, makeStyles } from '@material-ui/core/styles'
import { Container, CircularProgress } from '@material-ui/core' import { ReactElement, useContext, useEffect, useState } from 'react'
import { PlusSquare } from 'react-feather'
import StampsTable from './StampsTable' import { SwarmButton } from '../../components/SwarmButton'
import CreatePostageStampModal from './CreatePostageStampModal'
import { Context as StampsContext } from '../../providers/Stamps'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard' import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { Context as BeeContext } from '../../providers/Bee' import { Context as BeeContext } from '../../providers/Bee'
import { Context as StampsContext } from '../../providers/Stamps'
import { CreatePostageStampModal } from './CreatePostageStampModal'
import StampsTable from './StampsTable'
const useStyles = makeStyles(() => const useStyles = makeStyles(() =>
createStyles({ createStyles({
@@ -25,8 +25,11 @@ const useStyles = makeStyles(() =>
}), }),
) )
export default function Accounting(): ReactElement { export default function Stamp(): ReactElement {
const classes = useStyles() const classes = useStyles()
const [isBuyingStamp, setBuyingStamp] = useState(false)
const { stamps, isLoading, error, start, stop } = useContext(StampsContext) const { stamps, isLoading, error, start, stop } = useContext(StampsContext)
const { status } = useContext(BeeContext) const { status } = useContext(BeeContext)
@@ -49,7 +52,11 @@ export default function Accounting(): ReactElement {
{!error && ( {!error && (
<> <>
<div className={classes.actions}> <div className={classes.actions}>
<CreatePostageStampModal /> {isBuyingStamp ? <CreatePostageStampModal onClose={() => setBuyingStamp(false)} /> : null}
<SwarmButton onClick={() => setBuyingStamp(true)} iconType={PlusSquare}>
Buy New Postage Stamp
</SwarmButton>
<div style={{ height: '5px' }}>{isLoading && <CircularProgress />}</div> <div style={{ height: '5px' }}>{isLoading && <CircularProgress />}</div>
</div> </div>
<StampsTable postageStamps={stamps} /> <StampsTable postageStamps={stamps} />
@@ -1,14 +1,13 @@
import { ReactElement, useContext } from 'react'
import MuiAlert from '@material-ui/lab/Alert' import MuiAlert from '@material-ui/lab/Alert'
import { ReactElement, useContext } from 'react'
import CodeBlockTabs from '../../../components/CodeBlockTabs' import CodeBlockTabs from '../../../components/CodeBlockTabs'
import ExpandableList from '../../../components/ExpandableList' import ExpandableList from '../../../components/ExpandableList'
import ExpandableListItem from '../../../components/ExpandableListItem' import ExpandableListItem from '../../../components/ExpandableListItem'
import ExpandableListItemInput from '../../../components/ExpandableListItemInput' import ExpandableListItemInput from '../../../components/ExpandableListItemInput'
import ExpandableListItemNote from '../../../components/ExpandableListItemNote' import ExpandableListItemNote from '../../../components/ExpandableListItemNote'
import StatusIcon from '../../../components/StatusIcon' import StatusIcon from '../../../components/StatusIcon'
import { Context as SettingsContext } from '../../../providers/Settings'
import { Context } from '../../../providers/Bee' import { Context } from '../../../providers/Bee'
import { Context as SettingsContext } from '../../../providers/Settings'
export default function NodeConnectionCheck(): ReactElement | null { export default function NodeConnectionCheck(): ReactElement | null {
const { status, isLoading } = useContext(Context) const { status, isLoading } = useContext(Context)
@@ -25,7 +24,7 @@ export default function NodeConnectionCheck(): ReactElement | null {
> >
<ExpandableListItemNote> <ExpandableListItemNote>
{isOk {isOk
? 'The connection to the Bee nodes deug API has been successful' ? 'The connection to the Bee nodes debug API has been successful'
: 'We cannot connect to your nodes debug API. Please check the following to troubleshoot your issue.'} : 'We cannot connect to your nodes debug API. Please check the following to troubleshoot your issue.'}
</ExpandableListItemNote> </ExpandableListItemNote>
<ExpandableListItemInput label="Bee Debug API" value={apiDebugUrl} onConfirm={setDebugApiUrl} /> <ExpandableListItemInput label="Bee Debug API" value={apiDebugUrl} onConfirm={setDebugApiUrl} />
+24
View File
@@ -0,0 +1,24 @@
export class SwarmFile {
public name: string
public path: string
public type: string
public size: number
public webkitRelativePath: string
public arrayBuffer: () => Promise<ArrayBuffer>
private data: Promise<ArrayBuffer>
constructor(file: File) {
const path = Reflect.get(file, 'path') || file.webkitRelativePath || file.name
this.path = path.startsWith('/') ? path.slice(1) : path
this.webkitRelativePath = this.path
this.name = file.name
this.type = file.type
this.size = file.size
this.data = file.arrayBuffer()
this.arrayBuffer = async () => {
const data = await this.data
return data
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { detectIndexHtml } from './file'
describe('file utils', () => {
it('detectIndexHtml should find index.html', () => {
expect(
detectIndexHtml([
{ name: 'swarm.png', path: 'swarm.png' },
{ name: 'index.html', path: 'index.html' },
]),
).toBe('index.html')
})
it('detectIndexHtml should find index.htm', () => {
expect(
detectIndexHtml([
{ name: 'index.htm', path: 'index.htm' },
{ name: 'swarm.png', path: 'swarm.png' },
]),
).toBe('index.htm')
})
it('detectIndexHtml should find nested index.html', () => {
expect(
detectIndexHtml([
{ name: 'swarm.png', path: 'sample-folder/swarm.png' },
{ name: 'index.html', path: 'sample-folder/index.html' },
]),
).toBe('index.html')
})
it('detectIndexHtml should not find nested index.htm when ambigous', () => {
expect(
detectIndexHtml([
{ name: 'index.htm', path: 'sample-folder/index.htm' },
{ name: 'swarm.png', path: 'other-folder/swarm.png' },
]),
).toBe(false)
})
it('detectIndexHtml should not find deep index.html', () => {
expect(
detectIndexHtml([
{ name: 'index.html', path: 'sample-folder/index.html' },
{ name: 'swarm.png', path: 'swarm.png' },
]),
).toBe(false)
})
it('detectIndexHtml should return false when no matches appear', () => {
expect(
detectIndexHtml([
{ name: 'swarm.png', path: 'swarm.png' },
{ name: 'swarm.jpg', path: 'swarm.jpg' },
]),
).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
import { FileData } from '@ethersphere/bee-js'
import { SwarmFile } from './SwarmFile'
const indexHtmls = ['index.html', 'index.htm']
export function detectIndexHtml(files: SwarmFile[]): string | false {
if (!files.length) {
return false
}
const exactMatch = files.find(x => indexHtmls.includes(x.path))
if (exactMatch) {
return exactMatch.name
}
const prefix = files[0].path.split('/')[0] + '/'
const allStartWithSamePrefix = files.every(x => x.path.startsWith(prefix))
if (allStartWithSamePrefix) {
const match = files.find(x => indexHtmls.map(y => prefix + y).includes(x.path))
if (match) {
return match.name
}
}
return false
}
export function getHumanReadableFileSize(bytes: number): string {
if (bytes >= 1e6) {
return (bytes / 1e6).toFixed(2) + ' MB'
}
if (bytes >= 1e3) {
return (bytes / 1e3).toFixed(2) + ' kB'
}
return bytes + ' bytes'
}
export function convertBeeFileToBrowserFile(file: FileData<ArrayBuffer>): Partial<File> {
return {
name: file.name,
size: file.data.byteLength,
type: file.contentType,
arrayBuffer: () => new Promise(resolve => resolve(file.data)),
}
}
+47 -1
View File
@@ -1,5 +1,5 @@
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import { isInteger, makeBigNumber } from './index' import { extractSwarmHash, isInteger, makeBigNumber } from './index'
describe('utils', () => { describe('utils', () => {
describe('isInteger', () => { describe('isInteger', () => {
@@ -57,4 +57,50 @@ describe('utils', () => {
}) })
}) })
}) })
describe('extractSwarmHash', () => {
test('should return 64 hash', () => {
expect(extractSwarmHash('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3')).toBe(
'7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3',
)
})
test('should return 128 hash', () => {
expect(
extractSwarmHash(
'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f',
),
).toBe(
'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f',
)
})
test('should return 64 hash from url', () => {
expect(
extractSwarmHash('http://localhost:1633/bzz/7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3/'),
).toBe('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3')
})
test('should return 128 hash from url', () => {
expect(
extractSwarmHash(
'http://localhost:1633/bzz/d1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f/',
),
).toBe(
'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f',
)
})
test('should return null when nothing is found', () => {
expect(extractSwarmHash('Bee Dashboard')).toBe(null)
})
test('should return null when length is incorrect', () => {
expect(extractSwarmHash('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81a')).toBe(null)
})
test('should return null when alphanumeric', () => {
expect(extractSwarmHash('gkQ6duo5iHJ099g908P0t17ZWFf8Ke2klrywLP5BGtLkcaEC5W0kLEfbe4wUnDI6')).toBe(null)
})
})
}) })
+6
View File
@@ -106,3 +106,9 @@ export function makeRetriablePromise<T>(fn: () => Promise<T>, maxRetries = 3, de
} }
}) })
} }
export function extractSwarmHash(string: string): string | null {
const matches = string.match(/[a-fA-F0-9]{64,128}/)
return (matches && matches[0]) || null
}