Finish initial client main view

This commit is contained in:
Tulir Asokan 2018-11-09 20:03:26 +02:00
parent ef3f4a20f2
commit 29adf50ae0
9 changed files with 370 additions and 111 deletions

View File

@ -72,7 +72,7 @@ export async function uploadPlugin(data, id) {
let resp let resp
if (id) { if (id) {
resp = await fetch(`${BASE_PATH}/plugin/${id}`, { resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
headers: getHeaders("applcation/zip"), headers: getHeaders("application/zip"),
body: data, body: data,
method: "PUT", method: "PUT",
}) })
@ -96,9 +96,27 @@ export async function getClient(id) {
return await resp.json() return await resp.json()
} }
export async function uploadAvatar(id, data, mime) {
const resp = await fetch(`${BASE_PATH}/client/${id}/avatar`, {
headers: getHeaders(mime),
body: data,
method: "POST",
})
return await resp.json()
}
export async function putClient(client) {
const resp = await fetch(`${BASE_PATH}/client/${client.id}`, {
headers: getHeaders(),
body: JSON.stringify(client),
method: "PUT",
})
return await resp.json()
}
export default { export default {
login, ping, login, ping,
getInstances, getInstance, getInstances, getInstance,
getPlugins, getPlugin, uploadPlugin, getPlugins, getPlugin, uploadPlugin,
getClients, getClient, getClients, getClient, uploadAvatar, putClient,
} }

View File

@ -0,0 +1,55 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React from "react"
import Switch from "./Switch"
export const PrefTable = ({ children, wrapperClass }) => {
if (wrapperClass) {
return (
<div className={wrapperClass}>
<div className="preference-table">
{children}
</div>
</div>
)
}
return (
<div className="preference-table">
{children}
</div>
)
}
export const PrefRow = ({ name, children }) => (
<div className="row">
<div className="key">{name}</div>
<div className="value">{children}</div>
</div>
)
export const PrefInput = ({ rowName, ...args }) => (
<PrefRow name={rowName}>
<input {...args}/>
</PrefRow>
)
export const PrefSwitch = ({ rowName, ...args }) => (
<PrefRow name={rowName}>
<Switch {...args}/>
</PrefRow>
)
export default PrefTable

View File

@ -15,11 +15,16 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React, { Component } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import Switch from "../../components/Switch"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg" import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg" import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
function getAvatarURL(client) { function getAvatarURL(client) {
if (!client.avatar_url) {
return ""
}
const id = client.avatar_url.substr("mxc://".length) const id = client.avatar_url.substr("mxc://".length)
return `${client.homeserver}/_matrix/media/r0/download/${id}` return `${client.homeserver}/_matrix/media/r0/download/${id}`
} }
@ -45,68 +50,136 @@ class Client extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = props this.state = Object.assign(this.initialState, props.client)
}
get initialState() {
return {
id: "",
displayname: "",
homeserver: "",
avatar_url: "",
access_token: "",
sync: true,
autojoin: true,
enabled: true,
started: false,
uploadingAvatar: false,
saving: false,
startingOrStopping: false,
}
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
this.setState(nextProps) this.setState(Object.assign(this.initialState, nextProps.client))
} }
inputChange = event => { inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value }) this.setState({ [event.target.name]: event.target.value })
} }
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
avatarUpload = async event => {
const file = event.target.files[0]
this.setState({
uploadingAvatar: true,
})
const data = await this.readFile(file)
const resp = await api.uploadAvatar(this.state.id, data, file.type)
this.setState({
uploadingAvatar: false,
avatar_url: resp.content_uri,
})
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putClient(this.state)
if (resp.id) {
resp.saving = false
this.setState(resp)
} else {
console.error(resp)
}
}
startOrStop = async () => {
this.setState({ startingOrStopping: true })
const resp = await api.putClient({
id: this.state.id,
started: !this.state.started,
})
if (resp.id) {
resp.startingOrStopping = false
this.setState(resp)
} else {
console.error(resp)
}
}
render() { render() {
return <div className="client"> return <div className="client">
<div className="avatar-container"> <div className="sidebar">
<img className="avatar" src={getAvatarURL(this.state)} alt="Avatar"/> <div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
<UploadButton className="upload"/> ${this.state.uploadingAvatar ? "uploading" : ""}`}>
<img className="avatar" src={getAvatarURL(this.state)} alt="Avatar"/>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="image/png, image/jpeg"
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}/>
{this.state.uploadingAvatar && <Spinner/>}
</div>
{this.props.client && (<>
<div className="started-container">
<span className={`started ${this.state.started}`}/>
<span className="text">{this.state.started ? "Started" : "Stopped"}</span>
</div>
<button className="save" onClick={this.startOrStop}
disabled={this.state.saving || this.state.startingOrStopping}>
{this.state.startingOrStopping ? <Spinner/>
: (this.state.started ? "Stop" : "Start")}
</button>
</>)}
</div> </div>
<div className="info-container"> <div className="info-container">
<div className="row"> <PrefTable>
<div className="key">User ID</div> <PrefInput rowName="User ID" type="text" disabled={!!this.props.client}
<div className="value"> name={this.props.client ? "" : "id"}
<input type="text" disabled value={this.props.id} value={this.state.id} placeholder="@fancybot:example.com"
onChange={this.inputChange}/> onChange={this.inputChange}/>
</div> <PrefInput rowName="Display name" type="text" name="displayname"
</div> value={this.state.displayname} placeholder="My fancy bot"
<div className="row">
<div className="key">Display name</div>
<div className="value">
<input type="text" name="displayname" value={this.state.displayname}
onChange={this.inputChange}/> onChange={this.inputChange}/>
</div> <PrefInput rowName="Homeserver" type="text" name="homeserver"
</div> value={this.state.homeserver} placeholder="https://example.com"
<div className="row">
<div className="key">Homeserver</div>
<div className="value">
<input type="text" name="homeserver" value={this.state.homeserver}
onChange={this.inputChange}/> onChange={this.inputChange}/>
</div> <PrefInput rowName="Access token" type="text" name="access_token"
</div> value={this.state.access_token} onChange={this.inputChange}
<div className="row"> placeholder="MDAxYWxvY2F0aW9uIG1hdHJpeC5sb2NhbAowMDEzaWRlbnRpZmllc"/>
<div className="key">Access token</div> <PrefSwitch rowName="Sync" active={this.state.sync}
<div className="value">
<input type="text" name="access_token" value={this.state.access_token}
onChange={this.inputChange}/>
</div>
</div>
<div className="row">
<div className="key">Sync</div>
<div className="value">
<Switch active={this.state.sync}
onToggle={sync => this.setState({ sync })}/> onToggle={sync => this.setState({ sync })}/>
</div> <PrefSwitch rowName="Autojoin" active={this.state.autojoin}
</div> onToggle={autojoin => this.setState({ autojoin })}/>
<div className="row"> <PrefSwitch rowName="Enabled" active={this.state.enabled}
<div className="key">Enabled</div>
<div className="value">
<Switch active={this.state.enabled}
onToggle={enabled => this.setState({ enabled })}/> onToggle={enabled => this.setState({ enabled })}/>
</div> </PrefTable>
</div>
</div>
<button className="save" onClick={this.save}
disabled={this.state.saving || this.state.startingOrStopping}>
{this.state.saving ? <Spinner/> : "Save"}
</button>
</div>
</div> </div>
} }
} }

View File

@ -58,11 +58,19 @@ class Dashboard extends Component {
} }
renderView(field, type, id) { renderView(field, type, id) {
const entry = this.state[field + "s"][id] const stateField = field + "s"
const entry = this.state[stateField][id]
if (!entry) { if (!entry) {
return "Not found :(" return "Not found :("
} }
return React.createElement(type, entry) return React.createElement(type, {
[field]: entry,
onChange: newEntry => this.setState({
[stateField]: Object.assign({}, this.state[stateField], {
[id]: newEntry,
}),
}),
})
} }
render() { render() {
@ -72,7 +80,9 @@ class Dashboard extends Component {
Maubot Manager Maubot Manager
</Link> </Link>
<div className="topbar"> <div className="topbar">
{localStorage.username} <div className="user">
{localStorage.username}
</div>
</div> </div>
<nav className="sidebar"> <nav className="sidebar">
<div className="instances list"> <div className="instances list">

View File

@ -25,10 +25,12 @@
color: $inverted-text-color color: $inverted-text-color
box-sizing: border-box box-sizing: border-box
font-size: 1rem font-size: 1rem
cursor: pointer
&:hover &:not(:disabled)
background-color: darken($background, 10%) cursor: pointer
&:hover
background-color: darken($background, 10%)
=link-button() =link-button()
display: inline-block display: inline-block
@ -37,7 +39,7 @@
=main-color-button() =main-color-button()
background-color: $primary background-color: $primary
&:hover &:hover:not(:disabled)
background-color: $primary-dark background-color: $primary-dark
.button .button

View File

@ -14,9 +14,12 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
@import lib/spinner @import lib/spinner
@import base/vars @import base/vars
@import base/body @import base/body
@import base/elements @import base/elements
@import lib/preferencetable
@import lib/switch @import lib/switch
@import pages/login @import pages/login

View File

@ -0,0 +1,57 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2018 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
.preference-table
display: table
width: 100%
> .row
display: table-row
> .key, > .value
display: table-cell
padding-bottom: .5rem
> .key
width: 7rem
> .value
margin: .5rem
> .switch
width: auto
height: 2rem
> input
border: none
height: 2rem
width: 100%
box-sizing: border-box
padding: .375rem 0
background-color: $background
font-size: 1rem
border-bottom: 1px dotted $primary
&:hover:not(:disabled)
border-bottom: 1px solid $primary
&:focus:not(:disabled)
border-bottom: 2px solid $primary

View File

@ -16,85 +16,116 @@
> .client > .client
margin: 1rem margin: 1rem
display: flex
div.avatar-container > div.sidebar
position: relative
display: inline-block
width: 8rem
height: 8rem
border-radius: 100%
cursor: pointer
vertical-align: top vertical-align: top
text-align: center
> img.avatar > div
display: block margin-bottom: 1rem
max-width: 8rem
max-height: 8rem
border-radius: 100%
position: absolute
left: 50%
top: 50%
-webkit-transform: translateY(-50%) translateX(-50%)
> svg.upload > div.avatar-container
position: absolute position: relative
display: block width: 8rem
visibility: hidden height: 8rem
border-radius: 50%
overflow: hidden
width: 6rem display: flex
height: 6rem align-items: center
justify-content: center
padding: 1rem
&:hover
> img.avatar > img.avatar
opacity: .25 position: absolute
display: block
max-width: 8rem
max-height: 8rem
user-select: none
> svg.upload > svg.upload
visibility: visible position: absolute
display: block
visibility: hidden
div.info-container width: 6rem
display: inline-table height: 6rem
vertical-align: top
margin: 1rem 2rem padding: 1rem
user-select: none
> .row > input.file-selector
display: table-row position: absolute
width: 8rem
height: 8rem
user-select: none
opacity: 0
> .key, > .value > div.spinner
display: table-cell +thick-spinner
padding-bottom: .5rem
> .key &:not(.uploading)
width: 6.5rem > input.file-selector
cursor: pointer
> .value &:hover
> img.avatar
opacity: .25
> svg.upload
visibility: visible
&.no-avatar
> img.avatar
visibility: hidden
> svg.upload
visibility: visible
opacity: .5
&.uploading
> img.avatar
opacity: .25
> div.started-container
display: inline-flex
> span.started
display: inline-block
height: 0
width: 0
border-radius: 50%
margin: .5rem margin: .5rem
> .value > .switch &.true
width: auto background-color: $primary
height: 2rem box-shadow: 0 0 .75rem .75rem $primary
> .value > input &.false
border: none background-color: $error-light
height: 2rem box-shadow: 0 0 .75rem .75rem $error-light
width: 100%
box-sizing: border-box > span.text
display: inline-block
margin-left: 1rem
padding: .375rem 0 > div.info-container
background-color: $background vertical-align: top
font-size: 1rem margin: 0 1rem
flex: 1
border-bottom: 1px solid transparent button.save
+button
+main-color-button
width: 100%
height: 2.5rem
padding: 0
&:hover:not(:disabled) > .spinner
border-bottom: 1px solid $primary +thick-spinner
+white-spinner
&:focus:not(:disabled) width: 2rem
border-bottom: 2px solid $primary
//> .client //> .client
display: table display: table

View File

@ -46,9 +46,19 @@
grid-area: topbar grid-area: topbar
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: right
background-color: $primary background-color: $primary
box-shadow: 0 .25rem .25rem rgba(0, 0, 0, .2) box-shadow: 0 .25rem .25rem rgba(0, 0, 0, .2)
padding: .5rem 1rem
> div.user
display: inline-flex
align-items: center
height: 100%
padding: 0 1rem
box-sizing: border-box
background-color: $primary-dark
border-radius: .25rem
@import "sidebar" @import "sidebar"