Compare commits
72 Commits
Author | SHA1 | Date | |
---|---|---|---|
9fbbf821f0 | |||
d51b815b92 | |||
![]() |
ab721b9a7a | ||
![]() |
bc37b903b3 | ||
![]() |
f49ff032d4 | ||
![]() |
7477319a68 | ||
![]() |
ecd56b9ced | ||
![]() |
f393c767d3 | ||
![]() |
6facf41dba | ||
![]() |
8d0361cd80 | ||
![]() |
b72efdeeda | ||
![]() |
c8a52981e6 | ||
![]() |
c16b64bbf7 | ||
![]() |
e0c3d48b01 | ||
![]() |
60943ca082 | ||
![]() |
431be1bf5a | ||
![]() |
1e8416046e | ||
![]() |
09684200e4 | ||
![]() |
f5c40d54ff | ||
![]() |
41f15ae295 | ||
![]() |
243967259c | ||
![]() |
81e0d5fbdc | ||
![]() |
8d7375af4f | ||
![]() |
e15f23b535 | ||
![]() |
ebbd030e84 | ||
![]() |
54b9dbea79 | ||
![]() |
b7f609ecbb | ||
![]() |
f9b91cc7ca | ||
![]() |
c7a4d3413f | ||
![]() |
c38d37e0c8 | ||
![]() |
cb65ed446f | ||
![]() |
db429cf51f | ||
![]() |
18b20c2c60 | ||
![]() |
714381d24c | ||
![]() |
a070bcf32c | ||
![]() |
cba4a29f36 | ||
![]() |
17e4bee903 | ||
![]() |
250ca3d1f8 | ||
![]() |
0e0129acb5 | ||
![]() |
d70cf64e68 | ||
![]() |
70492274df | ||
![]() |
b5f42a7a28 | ||
![]() |
322af54ec6 | ||
![]() |
429982bb9b | ||
![]() |
a1b92f5587 | ||
![]() |
dbd7956491 | ||
![]() |
dfc83360e5 | ||
![]() |
320d2666ec | ||
![]() |
613817fc46 | ||
![]() |
9f2264b1f4 | ||
![]() |
116ecdfb72 | ||
![]() |
1b032ec4c7 | ||
![]() |
b50a91b6b5 | ||
![]() |
c61fb12463 | ||
![]() |
2e55399f53 | ||
![]() |
0cdd5d2c79 | ||
![]() |
8f0442876c | ||
![]() |
a3ece750f3 | ||
![]() |
4b9e09673e | ||
![]() |
41c4536d40 | ||
![]() |
a15bb2bcc8 | ||
![]() |
7f6216f032 | ||
![]() |
423df7cfb1 | ||
![]() |
475b8d0845 | ||
![]() |
b9b69f349e | ||
![]() |
1fa2a994b7 | ||
![]() |
ce81c77c88 | ||
![]() |
9d1dbc35f6 | ||
![]() |
9024466e6b | ||
![]() |
289d33c28a | ||
![]() |
704648bbc0 | ||
![]() |
959516283f |
2
.github/workflows/semantic-release.yml
vendored
2
.github/workflows/semantic-release.yml
vendored
@ -60,7 +60,7 @@ jobs:
|
||||
minor_pattern: "(MINOR)"
|
||||
# Indicate whether short tags like 'v1' should be supported. If false only full
|
||||
# tags like 'v1.0.0' will be recognized.
|
||||
short_tags: true
|
||||
short_tags: false
|
||||
# A string to determine the format of the version output
|
||||
format: "${major}.${minor}.${patch}"
|
||||
# If this is set to true, *every* commit will be treated as a new version.
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Description
|
||||
@ -86,14 +87,16 @@ To build the site you are expected to have npm and nodejs installed and have a a
|
||||
|
||||
Get the source files
|
||||
```
|
||||
git clone https://github.com/controlol/rclone-webui
|
||||
git clone ssh://git@code.swissmakers.ch:6022/michael.reber/rclone-webui.git
|
||||
cd rclone-webui
|
||||
```
|
||||
|
||||
Install dependencies
|
||||
`dnf install -y nodejs`
|
||||
`npm install`
|
||||
`npm ci`
|
||||
|
||||
Build the project
|
||||
`npm run build`
|
||||
`NODE_OPTIONS=--openssl-legacy-provider npm run build`
|
||||
|
||||
The WebUI should have been build in the build folder. Copy the files to a location you can easily access or use the build directory as the source for your rclone rcd.
|
||||
|
6776
package-lock.json
generated
6776
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
500
src/App.js
500
src/App.js
@ -1,7 +1,23 @@
|
||||
import { Component, Fragment } from 'react'
|
||||
import ReactTooltip from 'react-tooltip'
|
||||
|
||||
import { Container, HeaderContainer, InfosContainer, InfosWrapper, ItemsContainer, ActiveContainer, HistoryContainer, HistoryItem, HistoryItemsWrapper, LogoContainer, StatusContainer, StatusBulb, InfosRow } from './styled'
|
||||
import {
|
||||
Container,
|
||||
HeaderContainer,
|
||||
InfosContainer,
|
||||
InfosWrapper,
|
||||
ItemsContainer,
|
||||
ActiveContainer,
|
||||
Spinner,
|
||||
HistoryContainer,
|
||||
HistoryItem,
|
||||
HistoryItemsWrapper,
|
||||
LogoContainer,
|
||||
StatusContainer,
|
||||
StatusBulb,
|
||||
InfosRow,
|
||||
ActiveChecking
|
||||
} from './styled'
|
||||
|
||||
import API from './utils/API'
|
||||
import secondsToTimeString from './utils/timestring'
|
||||
@ -16,87 +32,35 @@ class App extends Component {
|
||||
super()
|
||||
this.state = {
|
||||
stats: {
|
||||
// bytes: 0,
|
||||
// checks: 0,
|
||||
// deletedDirs: 0,
|
||||
// deletes: 0,
|
||||
// elapsedTime: 0,
|
||||
// errors: 0,
|
||||
// eta: null,
|
||||
// fatalError: false,
|
||||
// renames: 0,
|
||||
// retryError: false,
|
||||
// speed: 0,
|
||||
// totalBytes: 0,
|
||||
// totalChecks: 0,
|
||||
// totalTransfers: 0,
|
||||
// transferTime: 0,
|
||||
// transfers: 0,
|
||||
// transferring: [
|
||||
// {
|
||||
// speed: 23534245,
|
||||
// group: "job/1",
|
||||
// size: 23457826345,
|
||||
// eta: 3600,
|
||||
// name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
|
||||
// }
|
||||
// ]
|
||||
elapsedTime: 0,
|
||||
transfers: 0,
|
||||
bytes: 0,
|
||||
errors: 0,
|
||||
lastError: '',
|
||||
transferring: [],
|
||||
checking: []
|
||||
},
|
||||
remotes: [{
|
||||
// name: "gdrive",
|
||||
// type: "drive",
|
||||
// bytes: 84265292526
|
||||
}],
|
||||
remotes: [],
|
||||
mounts: [],
|
||||
transferred: [],
|
||||
version: {
|
||||
// "arch": "amd64",
|
||||
// "decomposed": [
|
||||
// 1,
|
||||
// 56,
|
||||
// 2
|
||||
// ],
|
||||
// "goTags": "none",
|
||||
// "goVersion": "go1.16.8",
|
||||
// "isBeta": false,
|
||||
// "isGit": false,
|
||||
// "linking": "static",
|
||||
// "os": "linux",
|
||||
// "version": "v1.56.2"
|
||||
},
|
||||
transferred: [], // history
|
||||
version: {},
|
||||
endPointAvailable: true
|
||||
}
|
||||
|
||||
this.infoInterval = undefined
|
||||
this.timeInterval = undefined
|
||||
this.apiInterval = undefined
|
||||
this.infoInterval = null
|
||||
this.apiInterval = null
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
// fetch initial info
|
||||
componentDidMount() {
|
||||
this.fetchRemotes()
|
||||
this.fetchMounts()
|
||||
this.fetchVersionInfo()
|
||||
this.fetchInfos()
|
||||
|
||||
// get server stats every 5 seconds
|
||||
if (this.infoInterval === undefined) this.infoInterval = setInterval(this.fetchInfos, 5000)
|
||||
|
||||
// make the view look more responsive by counting every second
|
||||
if (this.timeInterval === undefined) this.timeInterval = setInterval(() => {
|
||||
let { stats } = this.state
|
||||
stats.elapsedTime++
|
||||
this.setState({ stats })
|
||||
}, 1000)
|
||||
|
||||
// check api status every second
|
||||
if (this.apiInterval === undefined) this.apiInterval = setInterval(this.checkApiEndpoint, 1000)
|
||||
this.infoInterval = setInterval(this.fetchInfos, 2000)
|
||||
this.apiInterval = setInterval(this.checkApiEndpoint, 5000)
|
||||
}
|
||||
|
||||
// clear the intervals
|
||||
componentWillUnmount = () => {
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.infoInterval)
|
||||
clearInterval(this.timeInterval)
|
||||
clearInterval(this.apiInterval)
|
||||
}
|
||||
|
||||
@ -106,223 +70,198 @@ class App extends Component {
|
||||
* also updates the favicon
|
||||
*/
|
||||
checkApiEndpoint = () => {
|
||||
if (API.getEndpointStatus() !== this.state.endPointAvailable) {
|
||||
this.setState({ endPointAvailable: !this.state.endPointAvailable })
|
||||
|
||||
const link = document.querySelectorAll("link[rel~='icon']");
|
||||
|
||||
link.forEach(v => {
|
||||
let segments = v.href.split(".")
|
||||
if (this.state.endPointAvailable) {
|
||||
segments[0] = segments[0].substring(0, segments[0].length - 3)
|
||||
} else {
|
||||
segments[0] += "-gs"
|
||||
}
|
||||
v.href = segments.join(".")
|
||||
})
|
||||
const status = API.getEndpointStatus()
|
||||
if (status !== this.state.endPointAvailable) {
|
||||
this.setState({ endPointAvailable: status })
|
||||
if (status) {
|
||||
this.fetchRemotes()
|
||||
this.fetchMounts()
|
||||
this.fetchVersionInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* get the configured remotes
|
||||
*/
|
||||
fetchRemotes = () => {
|
||||
return API.request({
|
||||
url: "/config/dump",
|
||||
"_group": "ui"
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data !== "object") throw new Error("invalid response")
|
||||
|
||||
let remotes = []
|
||||
|
||||
return Promise.all(Object.keys(response.data).map(v => {
|
||||
return new Promise((resolve, reject) => {
|
||||
return API.request({
|
||||
url: "/operations/about",
|
||||
data: {
|
||||
fs: v + ":"
|
||||
}
|
||||
})
|
||||
.then(({data}) => {
|
||||
if (typeof data !== "object" || isNaN(data.used)) return reject()
|
||||
|
||||
remotes.push({
|
||||
name: v,
|
||||
type: response.data[v].type,
|
||||
bytes: data.used
|
||||
})
|
||||
|
||||
return resolve()
|
||||
})
|
||||
.catch(reject)
|
||||
})
|
||||
API.request({ url: '/config/dump', _group: 'ui' })
|
||||
.then(res => {
|
||||
if (typeof res.data !== 'object') throw new Error()
|
||||
const remotes = []
|
||||
return Promise.all(
|
||||
Object.keys(res.data).map(name =>
|
||||
API.request({ url: '/operations/about', data: { fs: name + ':' } })
|
||||
.then(({ data }) => {
|
||||
if (typeof data !== 'object' || isNaN(data.used)) throw new Error()
|
||||
remotes.push({ name, type: res.data[name].type, bytes: data.used })
|
||||
})
|
||||
)
|
||||
).then(() => this.setState({ remotes }))
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
this.setState({ remotes })
|
||||
})
|
||||
.catch(err => console.error(err))
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* get the mounted volumes
|
||||
*/
|
||||
fetchMounts = () => {
|
||||
return API.request({
|
||||
url: "/mount/listmounts"
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data.mountPoints !== "object") throw new Error("invalid response")
|
||||
|
||||
this.setState({ mounts: response.data.mountPoints })
|
||||
})
|
||||
.catch(() => {})
|
||||
API.request({ url: '/mount/listmounts' })
|
||||
.then(res => {
|
||||
if (!Array.isArray(res.data.mountPoints)) throw new Error()
|
||||
this.setState({ mounts: res.data.mountPoints })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* get software versions and architecture info
|
||||
*/
|
||||
fetchVersionInfo = () => {
|
||||
return API.request({
|
||||
url: "/core/version"
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data !== "object") throw new Error("invalid response")
|
||||
|
||||
this.setState({ version: response.data })
|
||||
})
|
||||
.catch(() => {})
|
||||
API.request({ url: '/core/version' })
|
||||
.then(res => {
|
||||
if (typeof res.data !== 'object') throw new Error()
|
||||
this.setState({ version: res.data })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the server stats
|
||||
*/
|
||||
fetchInfos = () => {
|
||||
return API.request({
|
||||
url: "/core/stats"
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data !== "object") throw new Error("invalid response")
|
||||
const stats = response.data
|
||||
API.request({ url: '/core/stats' })
|
||||
.then(({ data: stats }) => {
|
||||
if (typeof stats !== 'object') throw new Error()
|
||||
|
||||
if (stats.transfers === 0) return this.setState({ stats })
|
||||
// store latest stats
|
||||
this.setState({ stats })
|
||||
|
||||
return API.request({
|
||||
url: "core/transferred"
|
||||
// update history if transfers > 0
|
||||
if (stats.transfers > 0) {
|
||||
API.request({ url: '/core/transferred' })
|
||||
.then(({ data }) => {
|
||||
let history = Array.isArray(data.transferred)
|
||||
? data.transferred
|
||||
.sort((a, b) => new Date(b.completed_at) - new Date(a.completed_at))
|
||||
.filter(v => v.error === '' && v.bytes > 0)
|
||||
: []
|
||||
history = history.slice(0, 20) // keep last 20
|
||||
this.setState({ transferred: history })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data.transferred !== "object") throw new Error("invalid response")
|
||||
|
||||
let transferred = []
|
||||
|
||||
response.data.transferred.sort((a,b) => new Date(b.completed_at) - new Date(a.completed_at)).forEach(v => {
|
||||
if (v.error.length === 0 && v.bytes > 0) return transferred.push(v)
|
||||
})
|
||||
|
||||
this.setState({ transferred, stats })
|
||||
})
|
||||
.catch(() => this.setState({ stats }))
|
||||
})
|
||||
.catch(() => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* renders the total speed of all transfers
|
||||
* @returns {String}
|
||||
*/
|
||||
renderLiveSpeed = () => {
|
||||
const transferring = this.state.stats.transferring
|
||||
|
||||
if (typeof transferring !== "object") return "0.00 MB/s";
|
||||
|
||||
const list = Array.isArray(this.state.stats.transferring)
|
||||
? this.state.stats.transferring
|
||||
: []
|
||||
let speed = 0
|
||||
|
||||
transferring.forEach(v => speed += v.speed)
|
||||
|
||||
list.forEach(item => {
|
||||
speed += typeof item?.speed === 'number' ? item.speed : 0
|
||||
})
|
||||
return bytesToString(speed, { speed: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* renders the list of remotes
|
||||
* @returns {Component}
|
||||
*/
|
||||
renderRemotes = () => {
|
||||
return this.state.remotes.map(v => (
|
||||
<InfosRow key={v.name} data-tip={bytesToString(v.bytes, { fixed: 2 })} data-for={"size"+v.MountPoint}>
|
||||
<p> {v.name} </p>
|
||||
<p> {v.type} </p>
|
||||
<ReactTooltip id={"size"+v.MountPoint} place="left" type="info" effect="solid" />
|
||||
const { remotes } = this.state
|
||||
if (!remotes.length) return null
|
||||
return remotes.map(r => (
|
||||
<InfosRow
|
||||
key={r.name}
|
||||
data-tip={bytesToString(r.bytes, { fixed: 2 })}
|
||||
data-for={'size-' + r.name}
|
||||
>
|
||||
<p>{r.name}</p>
|
||||
<p>{r.type}</p>
|
||||
<ReactTooltip
|
||||
id={'size-' + r.name}
|
||||
place="left"
|
||||
type="info"
|
||||
effect="solid"
|
||||
globalEventOff="click"
|
||||
/>
|
||||
</InfosRow>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* renders the list of mounts
|
||||
* @returns {Component}
|
||||
*/
|
||||
renderMounts = () => {
|
||||
return this.state.mounts.map(v => (
|
||||
<Fragment key={v.MountPoint} >
|
||||
<p> {v.Fs} </p>
|
||||
<p> {v.MountPoint} </p>
|
||||
const { mounts } = this.state
|
||||
if (!mounts.length) return null
|
||||
return mounts.map(m => (
|
||||
<Fragment key={m.MountPoint}>
|
||||
<p>{m.Fs}</p>
|
||||
<p>{m.MountPoint}</p>
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Each job gets it's own component and is rendered if there are any
|
||||
* Jobs are found by their transfer group
|
||||
* @returns {Job}
|
||||
*/
|
||||
renderActiveJobs = () => {
|
||||
if (typeof this.state.stats.transferring !== "object") return;
|
||||
const { transferring = [], checking = [] } = this.state.stats
|
||||
// collapse if nothing
|
||||
if (!transferring.length && !checking.length) return null
|
||||
|
||||
let activeJobIds = []
|
||||
// group transfers by job
|
||||
const groups = transferring.reduce((acc, item) => {
|
||||
if (!item.group) return acc
|
||||
acc[item.group] = acc[item.group] || []
|
||||
acc[item.group].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const { transferring } = this.state.stats
|
||||
// map to <Job>
|
||||
const transferJobs = Object.entries(groups).map(([grp, arr]) => (
|
||||
<Job
|
||||
key={grp}
|
||||
jobid={grp.replace(/\D/g, '')}
|
||||
refreshStats={this.fetchInfos}
|
||||
initialStats={{ transferring: arr }}
|
||||
/>
|
||||
))
|
||||
|
||||
transferring.forEach(v => {
|
||||
if (!activeJobIds.includes(v.group)) activeJobIds.push(v.group)
|
||||
})
|
||||
// now render checking rows
|
||||
const checkingRows = checking.map((name, i) => (
|
||||
<ActiveChecking key={i}>
|
||||
<p>{name}</p>
|
||||
<p>—</p>
|
||||
<p>Checking</p>
|
||||
<p>—</p>
|
||||
</ActiveChecking>
|
||||
))
|
||||
|
||||
return activeJobIds.map(group => {
|
||||
const fileTransfers = transferring.filter(v => v.group === group)
|
||||
// if no transfers but checks present, just show checks
|
||||
if (!transferJobs.length && checkingRows.length) {
|
||||
return <Fragment>{checkingRows}</Fragment>
|
||||
}
|
||||
|
||||
return <Job key={group} fileTransfers={fileTransfers} jobid={group.replace(/\D/g, '')} refreshStats={this.fetchInfos} />
|
||||
})
|
||||
// otherwise show transfers then checks
|
||||
return (
|
||||
<Fragment>
|
||||
{transferJobs}
|
||||
{checkingRows}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all history items
|
||||
* @returns {Component[]}
|
||||
*/
|
||||
renderHistory = () => {
|
||||
return this.state.transferred.map((v, i) => (
|
||||
<HistoryItem key={"transfer" + i}>
|
||||
<p> { v.name } </p>
|
||||
<p> { new Date(v.completed_at).toLocaleString() } </p>
|
||||
renderHistory = () =>
|
||||
this.state.transferred.map((v, i) => (
|
||||
<HistoryItem key={i}>
|
||||
<p>{v.name}</p>
|
||||
<p>{new Date(v.completed_at).toLocaleString()}</p>
|
||||
</HistoryItem>
|
||||
))
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const { stats, version, endPointAvailable } = this.state
|
||||
const { elapsedTime, transfers, totalTransfers, bytes, errors, lastError } = stats
|
||||
render() {
|
||||
const { stats, version, endPointAvailable, remotes, mounts } = this.state
|
||||
const { elapsedTime, transfers, bytes, errors, lastError, transferring } = stats
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<HeaderContainer>
|
||||
<LogoContainer>
|
||||
<img src="/favicon-64x64.png" alt="Rclone WebUI logo" width="64" height="64" />
|
||||
<h1> Rclone Dashboard </h1>
|
||||
<img src="/favicon-64x64.png" alt="Rclone Dashboard" width="64" height="64" />
|
||||
<h1>Rclone Dashboard</h1>
|
||||
</LogoContainer>
|
||||
|
||||
<StatusContainer>
|
||||
<StatusBulb style={{ background: endPointAvailable ? "var(--status-green)" : "var(--status-red)" }} />
|
||||
{ endPointAvailable ? "API endpoint is behaving normally" : "API endpoint is unavailable" }
|
||||
<StatusBulb
|
||||
style={{
|
||||
background: endPointAvailable ? 'var(--status-green)' : 'var(--status-red)'
|
||||
}}
|
||||
/>
|
||||
{endPointAvailable
|
||||
? 'API endpoint is behaving normally'
|
||||
: 'API endpoint is unavailable'}
|
||||
</StatusContainer>
|
||||
</HeaderContainer>
|
||||
|
||||
@ -330,60 +269,63 @@ class App extends Component {
|
||||
|
||||
<Container>
|
||||
<ItemsContainer>
|
||||
<ActiveContainer>
|
||||
<h1> Active Jobs </h1>
|
||||
{ this.renderActiveJobs() }
|
||||
</ActiveContainer>
|
||||
<ActiveContainer>
|
||||
<h1>
|
||||
Active Jobs
|
||||
{/* show spinner if any checking or transferring */}
|
||||
{(
|
||||
(this.state.stats.transferring?.length || 0) +
|
||||
(this.state.stats.checking?.length || 0)
|
||||
) > 0 && <Spinner />}
|
||||
</h1>
|
||||
{this.renderActiveJobs()}
|
||||
</ActiveContainer>
|
||||
<HistoryContainer>
|
||||
<h1> History </h1>
|
||||
<HistoryItemsWrapper>
|
||||
{ this.renderHistory() }
|
||||
</HistoryItemsWrapper>
|
||||
<h1>History</h1>
|
||||
<HistoryItemsWrapper>{this.renderHistory()}</HistoryItemsWrapper>
|
||||
</HistoryContainer>
|
||||
</ItemsContainer>
|
||||
|
||||
<InfosContainer>
|
||||
<InfosWrapper style={{ minHeight: "10rem" }}>
|
||||
<h2> Stats </h2>
|
||||
<p> Uptime </p>
|
||||
<p> { secondsToTimeString(elapsedTime, true) } </p>
|
||||
|
||||
<p> Speed </p>
|
||||
<p> { this.renderLiveSpeed() } </p>
|
||||
|
||||
<p> Active transfers </p>
|
||||
<p> { transfers + (transfers === 1 ? " file" : " files" )} </p>
|
||||
|
||||
<p> Total transfered files </p>
|
||||
<p> { totalTransfers + (totalTransfers === 1 ? " file" : " files" )} </p>
|
||||
|
||||
<p> Total transferred data </p>
|
||||
<p> { bytesToString(bytes, {}) } </p>
|
||||
<InfosWrapper style={{ minHeight: '10rem' }}>
|
||||
<h2>Service Stats</h2>
|
||||
<p>Uptime</p>
|
||||
<p>{secondsToTimeString(elapsedTime, true)}</p>
|
||||
<p>Speed</p>
|
||||
<p>{this.renderLiveSpeed()}</p>
|
||||
<p>Active transfers</p>
|
||||
<p>{`${(transferring || []).length} files`}</p>
|
||||
<p>Total transferred files</p>
|
||||
<p>{`${transfers || 0} files`}</p>
|
||||
<p>Total transferred data</p>
|
||||
<p>{bytesToString(bytes, {})}</p>
|
||||
</InfosWrapper>
|
||||
|
||||
<InfosWrapper style={{ minHeight: "6rem" }}>
|
||||
<h2> Remotes </h2>
|
||||
{ this.renderRemotes() }
|
||||
</InfosWrapper>
|
||||
|
||||
<InfosWrapper style={{ minHeight: "4.5rem" }}>
|
||||
<h2> Mounts </h2>
|
||||
{ this.renderMounts() }
|
||||
</InfosWrapper>
|
||||
|
||||
<Settings />
|
||||
|
||||
<InfosWrapper>
|
||||
<h2> System Info </h2>
|
||||
<p> Rclone version </p>
|
||||
<p> { version.version } </p>
|
||||
|
||||
<p> GO version </p>
|
||||
<p> { version.goVersion } </p>
|
||||
|
||||
<p> Architecture </p>
|
||||
<p> { version.arch } </p>
|
||||
<h2>Environment</h2>
|
||||
<p>Rclone version</p>
|
||||
<p>{version.version}</p>
|
||||
<p>GO version</p>
|
||||
<p>{version.goVersion}</p>
|
||||
<p>Architecture</p>
|
||||
<p>{version.arch}</p>
|
||||
</InfosWrapper>
|
||||
|
||||
{remotes.length > 0 && (
|
||||
<InfosWrapper style={{ minHeight: '6rem' }}>
|
||||
<h2>Remotes</h2>
|
||||
{this.renderRemotes()}
|
||||
</InfosWrapper>
|
||||
)}
|
||||
|
||||
{mounts.length > 0 && (
|
||||
<InfosWrapper style={{ minHeight: '4.5rem' }}>
|
||||
<h2>Mounts</h2>
|
||||
{this.renderMounts()}
|
||||
</InfosWrapper>
|
||||
)}
|
||||
|
||||
<Settings />
|
||||
</InfosContainer>
|
||||
</Container>
|
||||
</Fragment>
|
||||
|
6
src/assets/icons/checked.svg
Normal file
6
src/assets/icons/checked.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#3f79ad" d="M13 0.9l-1 1.1h-12v14h14v-10.5l1.7-2-2.7-2.6zM6.5 11.7l-4.2-4.2 1.4-1.4 2.7 2.7 6.6-6.6 1.4 1.4-7.9 8.1z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 457 B |
7
src/assets/icons/unchecked.svg
Normal file
7
src/assets/icons/unchecked.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by IcoMoon.io -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#3f79ad" d="M14 6.2v7.8h-12v-12h10.5l1-1h-12.5v14h14v-9.8z"></path>
|
||||
<path fill="#3f79ad" d="M7.9 10.9l-4.2-4.2 1.5-1.4 2.7 2.8 6.7-6.7 1.4 1.4z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 484 B |
52
src/components/LineLoader.jsx
Normal file
52
src/components/LineLoader.jsx
Normal file
@ -0,0 +1,52 @@
|
||||
import styled, { keyframes } from 'styled-components'
|
||||
|
||||
const BarWidthAnimation = keyframes`
|
||||
0% {
|
||||
width: 70%;
|
||||
}
|
||||
100% {
|
||||
width: 30%;
|
||||
}
|
||||
`
|
||||
|
||||
const BarPositionAnimation = keyframes`
|
||||
0% {
|
||||
left: -35%;
|
||||
}
|
||||
100% {
|
||||
left: 135%;
|
||||
}
|
||||
`
|
||||
|
||||
const LoaderContainer = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1000;
|
||||
background-color: rgba(255,255,255,.1);
|
||||
overflow: hidden;
|
||||
|
||||
::before, ::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
::after {
|
||||
background-color: var(--secondary-color);
|
||||
transform: translateX(-50%);
|
||||
animation: ${BarWidthAnimation} 2s infinite alternate, ${BarPositionAnimation} 2s infinite;
|
||||
}
|
||||
`
|
||||
|
||||
const LineLoader = props => {
|
||||
return <LoaderContainer />
|
||||
}
|
||||
|
||||
export default LineLoader
|
@ -15,11 +15,11 @@ export const ErrorContainer = styled.div`
|
||||
`
|
||||
|
||||
const Error = ({ errorCount, lastError }) => {
|
||||
const errorCountString = errorCount + ( errorCount === 1 ? " Error" : " Errors" )
|
||||
const errorCountString = (errorCount ? errorCount : 0) + ( errorCount === 1 ? " Error" : " Errors" )
|
||||
|
||||
const imageSource = errorCount > 0 ? ErrorCircle : CheckCircle
|
||||
|
||||
const ErrorToolTipString = errorCount > 0 ? `${errorCountString()} since Rclone started<br/>Last error: ${lastError}` : "0 errors since Rclone started"
|
||||
const ErrorToolTipString = errorCount > 0 ? `${errorCountString} since Rclone started<br/>Last error: ${lastError}` : "0 errors since Rclone started"
|
||||
|
||||
return (
|
||||
<ErrorContainer data-tip={ErrorToolTipString} data-for="error-cnt" >
|
||||
|
@ -1,129 +1,114 @@
|
||||
import { Component, Fragment } from "react"
|
||||
import { ActiveJob, ActiveTransfer, StopButton } from "../styled"
|
||||
import { Component, Fragment } from 'react'
|
||||
import { ActiveJob, ActiveTransfer, StopButton } from '../styled'
|
||||
import API from '../utils/API'
|
||||
import bytesToString from "../utils/bytestring"
|
||||
import secondsToTimeString from "../utils/timestring"
|
||||
import bytesToString from '../utils/bytestring'
|
||||
import secondsToTimeString from '../utils/timestring'
|
||||
|
||||
class Job extends Component {
|
||||
constructor() {
|
||||
super()
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
jobid: props.jobid,
|
||||
stats: {
|
||||
elapsedTime: 0,
|
||||
eta: 3600,
|
||||
bytes: 1024 * 1024 * 128,
|
||||
totalBytes: 1024 * 1024 * 1024,
|
||||
transferring: [
|
||||
{
|
||||
speed: 23534245,
|
||||
group: "job/1",
|
||||
size: 23457826345,
|
||||
eta: 3600,
|
||||
name: "a very very very long absurdly unneccesarily long name this is if you cant tell"
|
||||
}
|
||||
]
|
||||
},
|
||||
jobid: 0
|
||||
eta: 0,
|
||||
bytes: 0,
|
||||
totalBytes: 0,
|
||||
transferring: props.initialStats?.transferring ?? []
|
||||
}
|
||||
}
|
||||
|
||||
this.decreaseEtaInterval = undefined
|
||||
this.fetchStatsInterval = undefined
|
||||
this.fetchStatsInterval = null
|
||||
this.decreaseEtaInterval = null
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.fetchStats()
|
||||
|
||||
// make the view look more responsive by counting every second
|
||||
if (this.decreaseEtaInterval === undefined) this.decreaseEtaInterval = setInterval(() => {
|
||||
let { stats } = this.state
|
||||
|
||||
if (stats.eta > 1) stats.eta--
|
||||
stats.elapsedTime++
|
||||
stats.transferring?.forEach(v => {if (v.eta > 1) v.eta--})
|
||||
|
||||
this.setState({ stats })
|
||||
componentDidMount() {
|
||||
if (this.state.jobid) {
|
||||
this.fetchStats()
|
||||
this.fetchStatsInterval = setInterval(this.fetchStats, 5000)
|
||||
}
|
||||
this.decreaseEtaInterval = setInterval(() => {
|
||||
this.setState(({ stats }) => ({
|
||||
stats: {
|
||||
...stats,
|
||||
elapsedTime: stats.elapsedTime + 1,
|
||||
eta: Math.max(0, stats.eta - 1),
|
||||
transferring: (stats.transferring || []).map(v => ({
|
||||
...v,
|
||||
eta: Math.max(0, v.eta - 1)
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}, 1000)
|
||||
|
||||
// get job stats every 5 seconds
|
||||
if (this.fetchStatsInterval === undefined) this.fetchStatsInterval = setInterval(this.fetchStats, 5 * 1000)
|
||||
}
|
||||
|
||||
// clear the intervals
|
||||
componentWillUnmount = () => {
|
||||
clearInterval(this.decreaseEtaInterval)
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.fetchStatsInterval)
|
||||
clearInterval(this.decreaseEtaInterval)
|
||||
}
|
||||
|
||||
/**
|
||||
* get the stats from the job this component displays
|
||||
*/
|
||||
fetchStats = () => {
|
||||
return API.request({
|
||||
url: "/core/stats",
|
||||
data: {
|
||||
group: "job/" + this.props.jobid
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (typeof response.data !== "object") return new Error("invalid response")
|
||||
fetchStats = () =>
|
||||
API.request({ url: '/core/stats', data: { group: `job/${this.state.jobid}` } })
|
||||
.then(({ data }) => {
|
||||
if (typeof data === 'object') {
|
||||
this.setState({
|
||||
stats: {
|
||||
elapsedTime: data.elapsedTime ?? 0,
|
||||
eta: data.eta ?? 0,
|
||||
bytes: data.bytes ?? 0,
|
||||
totalBytes: data.totalBytes ?? 0,
|
||||
transferring: Array.isArray(data.transferring) ? data.transferring : []
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
this.setState({ stats: response.data })
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately cancels the current job
|
||||
*/
|
||||
stopJob = () => {
|
||||
return API.request({
|
||||
url: "/job/stop",
|
||||
data: {
|
||||
jobid: this.props.jobid
|
||||
}
|
||||
})
|
||||
.then(() => this.props.refreshStats())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* render each transfer with info
|
||||
*/
|
||||
renderActiveTransfer = () => {
|
||||
const { transferring } = this.state.stats
|
||||
|
||||
if (typeof transferring !== "object") return;
|
||||
stopJob = () =>
|
||||
API.request({ url: '/job/stop', data: { jobid: this.state.jobid } })
|
||||
.then(() => this.props.refreshStats())
|
||||
.catch(() => {})
|
||||
|
||||
renderActiveTransfer() {
|
||||
const { transferring = [] } = this.state.stats
|
||||
if (!transferring.length) return null
|
||||
return transferring.map(v => (
|
||||
<ActiveTransfer key={v.name}>
|
||||
<p> { v.name } </p>
|
||||
<p> { bytesToString(v.size, {}) } </p>
|
||||
<p> { secondsToTimeString(v.eta) } </p>
|
||||
<p> { bytesToString(v.speed, { speed: true }) } </p>
|
||||
<p>{v.name}</p>
|
||||
<p>{bytesToString(v.size, {})}</p>
|
||||
<p>{secondsToTimeString(v.eta)}</p>
|
||||
<p>{bytesToString(v.speed, { speed: true })}</p>
|
||||
</ActiveTransfer>
|
||||
))
|
||||
}
|
||||
|
||||
render = () => {
|
||||
const { stats } = this.state
|
||||
|
||||
render() {
|
||||
const { stats, jobid } = this.state
|
||||
return (
|
||||
<Fragment>
|
||||
<ActiveJob>
|
||||
<p>Time elapsed:</p>
|
||||
<p>{secondsToTimeString(stats.elapsedTime)}</p>
|
||||
|
||||
<ActiveJob>
|
||||
<p> Time elapsed: </p>
|
||||
<p> { secondsToTimeString(stats.elapsedTime) } </p>
|
||||
<p> Progress: </p>
|
||||
<p> { bytesToString(stats.bytes, { format: "GB", fixed: 3 }) } / { bytesToString(stats.totalBytes, { format: "GB", fixed: 3 }) } GB </p>
|
||||
<p>Progress:</p>
|
||||
<p>
|
||||
{bytesToString(stats.bytes, { format: 'GB', fixed: 3 })} /{' '}
|
||||
{bytesToString(stats.totalBytes, { format: 'GB', fixed: 3 })} GB
|
||||
</p>
|
||||
|
||||
<p> Time left: </p>
|
||||
<p> { secondsToTimeString(stats.eta) } </p>
|
||||
<p> Progress: </p>
|
||||
<p> { ((stats.bytes / stats.totalBytes) * 100 || 0).toFixed(2) } % </p>
|
||||
<p>Time left:</p>
|
||||
<p>{secondsToTimeString(stats.eta)}</p>
|
||||
|
||||
<StopButton onClick={this.stopJob}> Cancel </StopButton>
|
||||
</ActiveJob>
|
||||
{ this.renderActiveTransfer() }
|
||||
<p>Progress:</p>
|
||||
<p>{((stats.bytes / stats.totalBytes) * 100).toFixed(2)} %</p>
|
||||
|
||||
<p>Speed:</p>
|
||||
<p>{bytesToString(stats.speed, { speed: true })}</p>
|
||||
|
||||
{jobid ? <StopButton onClick={this.stopJob}>Cancel</StopButton> : null}
|
||||
</ActiveJob>
|
||||
|
||||
{/* Transfers only */}
|
||||
{this.renderActiveTransfer()}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
@ -1,13 +1,22 @@
|
||||
import styled from 'styled-components'
|
||||
import { Container } from '../styled'
|
||||
import { Button as normalButton, Container } from '../styled'
|
||||
|
||||
import Error from './error'
|
||||
|
||||
const NavigationContainer = styled(Container)`
|
||||
gap: 0 .5rem;
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
justify-content: center;
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled(normalButton)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 .3rem;
|
||||
`
|
||||
|
||||
const Navigation = ({ info }) => {
|
||||
const { errors, lastError } = info
|
||||
|
||||
|
@ -24,11 +24,13 @@ class Settings extends Component {
|
||||
"--popup-background": "#222",
|
||||
"--popup-header": "#333",
|
||||
"--box-gradient": "linear-gradient(#71caf220, #3f79ad33)",
|
||||
"--box-radial-gradient": "radial-gradient(#71caf220, transparent 150%)",
|
||||
"--status-red": "linear-gradient(#f56565aa, #c92222)",
|
||||
"--status-green": "linear-gradient(#95ee85aa, #3c891c)",
|
||||
"--text-color": "#fff",
|
||||
"--text-hover": "#eee",
|
||||
"--button-color": "#111",
|
||||
"--button-hover": "#181818"
|
||||
"--button-hover": "#1a1a1a"
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +127,7 @@ class Settings extends Component {
|
||||
renderSettings = () => {
|
||||
return (
|
||||
<PopupContainer>
|
||||
<PopupTitle> Settings </PopupTitle>
|
||||
<PopupTitle> Rclone Settings </PopupTitle>
|
||||
<Cross onClick={() => this.setState({ show: false })}> Close </Cross>
|
||||
|
||||
<pre>
|
||||
@ -149,7 +151,7 @@ class Settings extends Component {
|
||||
}
|
||||
|
||||
<InfosWrapper>
|
||||
<h2> Settings </h2>
|
||||
<h2> Rclone Settings </h2>
|
||||
<p> Theme </p>
|
||||
<Switch
|
||||
checked={darkTheme}
|
||||
@ -177,7 +179,7 @@ class Settings extends Component {
|
||||
<p> File min age </p>
|
||||
<p> { secondsToTimeString(settings?.filter?.MinAge / 1000000000, true) } </p>
|
||||
|
||||
<Button onClick={this.showSettings}> List Settings </Button>
|
||||
<Button onClick={this.showSettings}> Show full Configuration </Button>
|
||||
</InfosWrapper>
|
||||
</Fragment>
|
||||
)
|
||||
|
@ -1,19 +1,27 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap');
|
||||
|
||||
html {
|
||||
--scrollbarBG: #CFD8DC;
|
||||
--off-white: #adb2b5;
|
||||
--thumbBG: #90A4AE;
|
||||
|
||||
--background-color: #eee;
|
||||
--popup-background: #ddd;
|
||||
--popup-header: #ccc;
|
||||
--primary-color: #3f79ad;
|
||||
--secondary-color: #71caf2;
|
||||
--tertiary-color: #b4e3f9;
|
||||
--primary-color-trans: #3f79ad33;
|
||||
--secondary-color-trans: #71caf233;
|
||||
--text-color: #000;
|
||||
--text-hover: #222;
|
||||
|
||||
--box-gradient: linear-gradient(#71caf220, #3f79ad33);
|
||||
--box-radial-gradient: radial-gradient(#3f79ad33, transparent 150%);
|
||||
|
||||
--status-red: linear-gradient(#fd4444aa, #c92222);
|
||||
--status-green: linear-gradient(#3bc322aa, #3c891c);
|
||||
--warning-red: var(--status-red);
|
||||
|
||||
--button-color: #fafafa;
|
||||
--button-hover: #e4e4e4;
|
||||
@ -25,6 +33,22 @@ html {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
--background-color: #000;
|
||||
--popup-background: #222;
|
||||
--popup-header: #333;
|
||||
--box-gradient: linear-gradient(#71caf220, #3f79ad33);
|
||||
--box-radial-gradient: radial-gradient(#71caf220, transparent 150%);
|
||||
--status-red: linear-gradient(#f56565aa, #c92222);
|
||||
--status-green: linear-gradient(#95ee85aa, #3c891c);
|
||||
--text-color: #fff;
|
||||
--text-hover: #eee;
|
||||
--button-color: #111;
|
||||
--button-hover: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
@ -39,4 +63,27 @@ body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 11px;
|
||||
}
|
||||
|
||||
body {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbarBG);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--thumbBG) ;
|
||||
border-radius: 6px;
|
||||
border: 3px solid var(--scrollbarBG);
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Checked from './assets/icons/checked.svg'
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
gap: 0 2rem;
|
||||
@ -110,6 +112,7 @@ export const ActiveJob = styled.div`
|
||||
export const ActiveTransfer = styled.div`
|
||||
border-radius: .3rem;
|
||||
border: 1px solid var(--secondary-color);
|
||||
font-family: monospace;
|
||||
background-color: var(--secondary-color-trans);
|
||||
padding: .5rem;
|
||||
margin-left: 1.5rem;
|
||||
@ -138,6 +141,28 @@ export const ActiveTransfer = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
/* Same grid but a softer colour so checks are distinguishable */
|
||||
export const ActiveChecking = styled(ActiveTransfer)`
|
||||
background: var(--tertiary-color-trans);
|
||||
`;
|
||||
|
||||
export const Spinner = styled.div`
|
||||
display: inline-block;
|
||||
width: 1.2em; /* size relative to font */
|
||||
height: 1.2em;
|
||||
margin-left: 0.5em; /* space after the text */
|
||||
vertical-align: middle; /* align with text baseline */
|
||||
border: 0.2em solid var(--secondary-color-trans);
|
||||
border-top: 0.2em solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
|
||||
export const HistoryContainer = styled(ActiveContainer)`
|
||||
margin-top: 1rem;
|
||||
`
|
||||
@ -281,6 +306,9 @@ export const PopupContainer = styled.div`
|
||||
padding: 1rem 2rem;
|
||||
overflow-y: auto;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem 0;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
@ -288,11 +316,6 @@ export const PopupContainer = styled.div`
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
pre {
|
||||
margin-top: 4.5rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
@ -307,15 +330,13 @@ export const PopupTitle = styled.p`
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
background-color: var(--popup-header);
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
left: 50%;
|
||||
width: 80vw;
|
||||
padding: 1rem 0;
|
||||
margin-top: -1rem;
|
||||
margin: -1rem 0 0 -2rem;
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
width: 100vw;
|
||||
margin-left: -.5rem;
|
||||
}
|
||||
`
|
||||
|
||||
@ -324,10 +345,12 @@ export const Cross = styled.div`
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
right: 11.5vw;
|
||||
top: calc(3vh + .5rem);
|
||||
font-size: 1.25rem;
|
||||
|
||||
@media only screen and (max-width: 800px) {
|
||||
right: 5vw;
|
||||
top: .5rem;
|
||||
}
|
||||
`
|
||||
|
||||
@ -336,4 +359,54 @@ export const IconWrapper = styled.div`
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
`
|
||||
|
||||
export const Checkbox = styled.input`
|
||||
display: none;
|
||||
|
||||
&[type=checkbox] + label {
|
||||
position: relative;
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
&[type=checkbox] + label::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: .35rem;
|
||||
left: -1.45rem;
|
||||
width: .75rem;
|
||||
height: .75rem;
|
||||
outline: .1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
&[type=checkbox]:checked + label::after {
|
||||
background: url(${Checked});
|
||||
top: .15rem;
|
||||
left: -1.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
outline: unset;
|
||||
}
|
||||
`
|
||||
|
||||
export const Input = styled.input`
|
||||
width: 100%;
|
||||
padding: .6em 1em;
|
||||
margin: .5em 0;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--primary-color);
|
||||
background-color: var(--button-color);
|
||||
color: white;
|
||||
transition: border .3s ease-in-out;
|
||||
font-size: .9rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
`
|
||||
|
||||
export const WarningButton = styled(Button)`
|
||||
background: var(--warning-red);
|
||||
`
|
@ -37,7 +37,10 @@ class API {
|
||||
}
|
||||
|
||||
// log error and reject
|
||||
console.error(err)
|
||||
if (err?.response?.data?.error) {
|
||||
console.error(err.response.status, err.response.data.error)
|
||||
return reject(err.response.data.error)
|
||||
}
|
||||
return reject()
|
||||
})
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user