Add Cloudron packaging for Maubot

This commit is contained in:
Your Name
2025-11-13 21:00:23 -06:00
commit 1af2e64aae
179 changed files with 24749 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
{
"extends": "react-app",
"plugins": [
"import"
],
"rules": {
"indent": ["error", 4, {
"ignoredNodes": [
"JSXAttribute",
"JSXSpreadAttribute"
],
"FunctionDeclaration": {"parameters": "first"},
"FunctionExpression": {"parameters": "first"},
"CallExpression": {"arguments": "first"},
"ArrayExpression": "first",
"ObjectExpression": "first",
"ImportDeclaration": "first"
}],
"react/jsx-indent-props": ["error", "first"],
"object-curly-newline": ["error", {
"consistent": true
}],
"object-curly-spacing": ["error", "always", {
"arraysInObjects": false,
"objectsInObjects": false
}],
"array-bracket-spacing": ["error", "never"],
"one-var": ["error", {
"initialized": "never",
"uninitialized": "always"
}],
"one-var-declaration-per-line": ["error", "initializations"],
"quotes": ["error", "double"],
"semi": ["error", "never"],
"comma-dangle": ["error", "always-multiline"],
"max-len": ["warn", 100],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"func-style": ["warn", "declaration", {"allowArrowFunctions": true}],
"id-length": ["warn", {"max": 40, "exceptions": ["i", "j", "x", "y", "_"]}],
"arrow-body-style": ["error", "as-needed"],
"new-cap": ["warn", {
"newIsCap": true,
"capIsNew": true
}],
"no-empty": ["error", {
"allowEmptyCatch": true
}],
"eol-last": ["error", "always"],
"no-console": "off",
"import/no-nodejs-modules": "error",
"import/order": ["warn", {
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "never"
}]
}
}

View File

@@ -0,0 +1,5 @@
/node_modules
/build
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@@ -0,0 +1,27 @@
options:
merge-default-rules: false
formatter: html
max-warnings: 50
files:
include: 'src/style/**/*.sass'
rules:
extends-before-mixins: 2
extends-before-declarations: 2
placeholder-in-extend: 2
mixins-before-declarations:
- 2
- exclude:
- breakpoint
- mq
no-warn: 1
no-debug: 1
hex-notation:
- 2
- style: uppercase
indentation:
- 2
- size: 4
property-sort-order:
- 0

View File

@@ -0,0 +1,40 @@
{
"name": "maubot-manager",
"version": "0.1.1",
"private": true,
"author": "Tulir Asokan",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
"url": "git+https://github.com/maubot/maubot.git"
},
"bugs": {
"url": "https://github.com/maubot/maubot/issues"
},
"homepage": ".",
"dependencies": {
"react": "^17.0.2",
"react-ace": "^9.4.1",
"react-contextmenu": "^2.14.0",
"react-dom": "^17.0.2",
"react-json-tree": "^0.16.1",
"react-router-dom": "^5.3.0",
"react-scripts": "5.0.0",
"react-select": "^5.2.1",
"sass": "^1.34.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": [
"last 2 firefox versions",
"last 2 and_ff versions",
"last 2 chrome versions",
"last 2 and_chr versions",
"last 1 safari versions",
"last 1 ios_saf versions"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,37 @@
<!--
maubot - A plugin-based Matrix bot system.
Copyright (C) 2022 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/>.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#50D367">
<meta property="og:title" content="Maubot Manager"/>
<meta property="og:description" content="Maubot management interface"/>
<meta property="og:image" content="%PUBLIC_URL%/favicon.png"/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>Maubot Manager</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,15 @@
{
"short_name": "Maubot",
"name": "Maubot Manager",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 48x48 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#50D367",
"background_color": "#FAFAFA"
}

View File

@@ -0,0 +1,276 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
let BASE_PATH = "/_matrix/maubot/v1"
export function setBasePath(basePath) {
BASE_PATH = basePath
}
function getHeaders(contentType = "application/json") {
return {
"Content-Type": contentType,
"Authorization": `Bearer ${localStorage.accessToken}`,
}
}
async function defaultDelete(type, id) {
const resp = await fetch(`${BASE_PATH}/${type}/${id}`, {
headers: getHeaders(),
method: "DELETE",
})
if (resp.status === 204) {
return {
"success": true,
}
}
return await resp.json()
}
async function defaultPut(type, entry, id = undefined, suffix = undefined) {
const resp = await fetch(`${BASE_PATH}/${type}/${id || entry.id}${suffix || ""}`, {
headers: getHeaders(),
body: JSON.stringify(entry),
method: "PUT",
})
return await resp.json()
}
async function defaultGet(path) {
const resp = await fetch(`${BASE_PATH}${path}`, { headers: getHeaders() })
return await resp.json()
}
export async function login(username, password) {
const resp = await fetch(`${BASE_PATH}/auth/login`, {
method: "POST",
body: JSON.stringify({
username,
password,
}),
})
return await resp.json()
}
let features = null
export async function ping() {
const response = await fetch(`${BASE_PATH}/auth/ping`, {
method: "POST",
headers: getHeaders(),
})
const json = await response.json()
if (json.username) {
features = json.features
return json.username
} else if (json.errcode === "auth_token_missing" || json.errcode === "auth_token_invalid") {
if (!features) {
await remoteGetFeatures()
}
return null
}
throw json
}
export const remoteGetFeatures = async () => {
features = await defaultGet("/features")
}
export const getFeatures = () => features
export async function openLogSocket() {
let protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const url = `${protocol}//${window.location.host}${BASE_PATH}/logs`
const wrapper = {
socket: null,
connected: false,
authenticated: false,
onLog: data => undefined,
onHistory: history => undefined,
fails: -1,
}
const openHandler = () => {
wrapper.socket.send(localStorage.accessToken)
wrapper.connected = true
}
const messageHandler = evt => {
// TODO use logs
const data = JSON.parse(evt.data)
if (data.auth_success !== undefined) {
if (data.auth_success) {
console.info("Websocket connection authentication successful")
wrapper.authenticated = true
wrapper.fails = -1
} else {
console.info("Websocket connection authentication failed")
}
} else if (data.history) {
wrapper.onHistory(data.history)
} else {
wrapper.onLog(data)
}
}
const closeHandler = evt => {
if (evt) {
if (evt.code === 4000) {
console.error("Websocket connection failed: access token invalid or not provided")
} else if (evt.code === 1012) {
console.info("Websocket connection closed: server is restarting")
}
}
wrapper.connected = false
wrapper.socket = null
wrapper.fails++
const SECOND = 1000
setTimeout(() => {
wrapper.socket = new WebSocket(url)
wrapper.socket.onopen = openHandler
wrapper.socket.onmessage = messageHandler
wrapper.socket.onclose = closeHandler
}, Math.min(wrapper.fails * 5 * SECOND, 30 * SECOND))
}
closeHandler()
return wrapper
}
let _debugOpenFileEnabled = undefined
export const debugOpenFileEnabled = () => _debugOpenFileEnabled
export const updateDebugOpenFileEnabled = async () => {
const resp = await defaultGet("/debug/open")
_debugOpenFileEnabled = resp["enabled"] || false
}
export async function debugOpenFile(path, line) {
const resp = await fetch(`${BASE_PATH}/debug/open`, {
headers: getHeaders(),
body: JSON.stringify({ path, line }),
method: "POST",
})
return await resp.json()
}
export const getInstances = () => defaultGet("/instances")
export const getInstance = id => defaultGet(`/instance/${id}`)
export const putInstance = (instance, id) => defaultPut("instance", instance, id)
export const deleteInstance = id => defaultDelete("instance", id)
export const getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
export const queryInstanceDatabase = async (id, query) => {
const resp = await fetch(`${BASE_PATH}/instance/${id}/database/query`, {
headers: getHeaders(),
body: JSON.stringify({ query }),
method: "POST",
})
return await resp.json()
}
export const getPlugins = () => defaultGet("/plugins")
export const getPlugin = id => defaultGet(`/plugin/${id}`)
export const deletePlugin = id => defaultDelete("plugin", id)
export async function uploadPlugin(data, id) {
let resp
if (id) {
resp = await fetch(`${BASE_PATH}/plugin/${id}`, {
headers: getHeaders("application/zip"),
body: data,
method: "PUT",
})
} else {
resp = await fetch(`${BASE_PATH}/plugins/upload`, {
headers: getHeaders("application/zip"),
body: data,
method: "POST",
})
}
return await resp.json()
}
export const getClients = () => defaultGet("/clients")
export const getClient = id => defaultGet(`/clients/${id}`)
export async function uploadAvatar(id, data, mime) {
const resp = await fetch(`${BASE_PATH}/proxy/${id}/_matrix/media/v3/upload`, {
headers: getHeaders(mime),
body: data,
method: "POST",
})
return await resp.json()
}
export function getAvatarURL({ id, avatar_url }) {
if (!avatar_url?.startsWith("mxc://")) {
return null
}
avatar_url = avatar_url.substring("mxc://".length)
// Note: the maubot backend will replace the query param with an authorization header
return `${BASE_PATH}/proxy/${id}/_matrix/client/v1/media/download/${avatar_url}?access_token=${
localStorage.accessToken}`
}
export const putClient = client => defaultPut("client", client)
export const deleteClient = id => defaultDelete("client", id)
export async function clearClientCache(id) {
const resp = await fetch(`${BASE_PATH}/client/${id}/clearcache`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export async function verifyClient(id, recovery_key) {
const resp = await fetch(`${BASE_PATH}/client/${id}/verify`, {
headers: getHeaders(),
method: "POST",
body: JSON.stringify({ recovery_key }),
})
return await resp.json()
}
export async function generateRecoveryKey(id) {
const resp = await fetch(`${BASE_PATH}/client/${id}/generate_recovery_key`, {
headers: getHeaders(),
method: "POST",
})
return await resp.json()
}
export const getClientAuthServers = () => defaultGet("/client/auth/servers")
export async function doClientAuth(server, type, username, password) {
const resp = await fetch(`${BASE_PATH}/client/auth/${server}/${type}`, {
headers: getHeaders(),
body: JSON.stringify({ username, password }),
method: "POST",
})
return await resp.json()
}
// eslint-disable-next-line import/no-anonymous-default-export
export default {
login, ping, setBasePath, getFeatures, remoteGetFeatures,
openLogSocket,
debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, clearClientCache,
verifyClient, generateRecoveryKey,
getClientAuthServers, doClientAuth,
}

View File

@@ -0,0 +1,71 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 Select from "react-select"
import CreatableSelect from "react-select/creatable"
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, fullWidth = false, labelFor = undefined, changed = false, children }) => (
<div className={`entry ${fullWidth ? "full-width" : ""} ${changed ? "changed" : ""}`}>
<label htmlFor={labelFor}>{name}</label>
<div className="value">{children}</div>
</div>
)
export const PrefInput = ({ rowName, value, origValue, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origValue !== undefined && value !== origValue}>
<input {...args} value={value} id={rowName}/>
</PrefRow>
)
export const PrefSwitch = ({ rowName, active, origActive, fullWidth = false, ...args }) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origActive !== undefined && active !== origActive}>
<Switch {...args} active={active} id={rowName}/>
</PrefRow>
)
export const PrefSelect = ({
rowName, value, origValue, fullWidth = false, creatable = false, ...args
}) => (
<PrefRow name={rowName} fullWidth={fullWidth} labelFor={rowName}
changed={origValue !== undefined && value.id !== origValue}>
{creatable
? <CreatableSelect className="select" {...args} id={rowName} value={value}/>
: <Select className="select" {...args} id={rowName} value={value}/>}
</PrefRow>
)
export default PrefTable

View File

@@ -0,0 +1,28 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 { Route, Redirect } from "react-router-dom"
const PrivateRoute = ({ component, render, authed, to = "/login", ...args }) => (
<Route
{...args}
render={(props) => authed === true
? (component ? React.createElement(component, props) : render())
: <Redirect to={{ pathname: to }}/>}
/>
)
export default PrivateRoute

View File

@@ -0,0 +1,11 @@
import React from "react"
const Spinner = (props) => (
<div {...props} className={`spinner ${props["className"] || ""}`}>
<svg viewBox="25 25 50 50">
<circle cx="50" cy="50" r="20" fill="none" strokeWidth="2" strokeMiterlimit="10"/>
</svg>
</div>
)
export default Spinner

View File

@@ -0,0 +1,59 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
class Switch extends Component {
constructor(props) {
super(props)
this.state = {
active: props.active,
}
}
componentDidUpdate(prevProps) {
if (prevProps.active !== this.props.active) {
this.setState({
active: this.props.active,
})
}
}
toggle = () => {
if (this.props.onToggle) {
this.props.onToggle(!this.state.active)
} else {
this.setState({ active: !this.state.active })
}
}
toggleKeyboard = evt => (evt.key === " " || evt.key === "Enter") && this.toggle()
render() {
return (
<div className="switch" data-active={this.state.active} onClick={this.toggle}
tabIndex="0" onKeyPress={this.toggleKeyboard} id={this.props.id}>
<div className="box">
<span className="text">
<span className="on">{this.props.onText || "On"}</span>
<span className="off">{this.props.offText || "Off"}</span>
</span>
</div>
</div>
)
}
}
export default Switch

View File

@@ -0,0 +1,21 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 ReactDOM from "react-dom"
import "./style/index.sass"
import App from "./pages/Main"
ReactDOM.render(<App/>, document.getElementById("root"))

View File

@@ -0,0 +1,72 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
import Spinner from "../components/Spinner"
import api from "../api"
class Login extends Component {
constructor(props, context) {
super(props, context)
this.state = {
username: "",
password: "",
loading: false,
error: "",
}
}
inputChanged = event => this.setState({ [event.target.name]: event.target.value })
login = async evt => {
evt.preventDefault()
this.setState({ loading: true })
const resp = await api.login(this.state.username, this.state.password)
if (resp.token) {
await this.props.onLogin(resp.token)
} else if (resp.error) {
this.setState({ error: resp.error, loading: false })
} else {
this.setState({ error: "Unknown error", loading: false })
console.log("Unknown error:", resp)
}
}
render() {
if (!api.getFeatures().login) {
return <div className="login-wrapper">
<div className="login errored">
<h1>Maubot Manager</h1>
<div className="error">Login has been disabled in the maubot config.</div>
</div>
</div>
}
return <div className="login-wrapper">
<form className={`login ${this.state.error && "errored"}`} onSubmit={this.login}>
<h1>Maubot Manager</h1>
<input type="text" placeholder="Username" value={this.state.username}
name="username" onChange={this.inputChanged}/>
<input type="password" placeholder="Password" value={this.state.password}
name="password" onChange={this.inputChanged}/>
<button onClick={this.login} type="submit">
{this.state.loading ? <Spinner/> : "Log in"}
</button>
{this.state.error && <div className="error">{this.state.error}</div>}
</form>
</div>
}
}
export default Login

View File

@@ -0,0 +1,91 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
import { HashRouter as Router, Switch } from "react-router-dom"
import PrivateRoute from "../components/PrivateRoute"
import Spinner from "../components/Spinner"
import api from "../api"
import Dashboard from "./dashboard"
import Login from "./Login"
class Main extends Component {
constructor(props) {
super(props)
this.state = {
pinged: false,
authed: false,
}
}
async componentDidMount() {
await this.getBasePath()
if (localStorage.accessToken) {
await this.ping()
} else {
await api.remoteGetFeatures()
}
this.setState({ pinged: true })
}
async getBasePath() {
try {
const resp = await fetch(process.env.PUBLIC_URL + "/paths.json", {
headers: { "Content-Type": "application/json" },
})
const apiPaths = await resp.json()
api.setBasePath(apiPaths.api_path)
} catch (err) {
console.error("Failed to get API path:", err)
}
}
async ping() {
try {
const username = await api.ping()
if (username) {
localStorage.username = username
this.setState({ authed: true })
} else {
delete localStorage.accessToken
}
} catch (err) {
console.error(err)
}
}
login = async (token) => {
localStorage.accessToken = token
await this.ping()
}
render() {
if (!this.state.pinged) {
return <Spinner className="maubot-loading"/>
}
return <Router>
<div className={`maubot-wrapper ${this.state.authed ? "authenticated" : ""}`}>
<Switch>
<PrivateRoute path="/login" render={() => <Login onLogin={this.login}/>}
authed={!this.state.authed} to="/"/>
<PrivateRoute path="/" component={Dashboard} authed={this.state.authed}/>
</Switch>
</div>
</Router>
}
}
export default Main

View File

@@ -0,0 +1,98 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
import { Link } from "react-router-dom"
import api from "../../api"
class BaseMainView extends Component {
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.entry)
}
UNSAFE_componentWillReceiveProps(nextProps) {
const newState = Object.assign(this.initialState, nextProps.entry)
for (const key of this.entryKeys) {
if (this.props.entry[key] === nextProps.entry[key]) {
newState[key] = this.state[key]
}
}
this.setState(newState)
}
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await this.deleteFunc(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get entryKeys() {
return []
}
get initialState() {
return {}
}
get hasInstances() {
return this.state.instances && this.state.instances.length > 0
}
get isNew() {
return !this.props.entry.id
}
inputChange = event => {
if (!event.target.name) {
return
}
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)
})
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
renderLogButton = (filter) => !this.isNew && api.getFeatures().log && <div className="buttons">
<button className="open-log" onClick={() => this.props.openLog(filter)}>View logs</button>
</div>
}
export default BaseMainView

View File

@@ -0,0 +1,392 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
import { PrefTable, PrefSwitch, PrefInput, PrefSelect } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const ClientListEntry = ({ entry }) => {
const classes = ["client", "entry"]
if (!entry.enabled) {
classes.push("disabled")
} else if (!entry.started) {
classes.push("stopped")
}
const avatarMXC = entry.avatar_url === "disable"
? entry.remote_avatar_url
: entry.avatar_url
const avatarURL = avatarMXC && api.getAvatarURL({
id: entry.id,
avatar_url: avatarMXC,
})
const displayname = (
entry.displayname === "disable"
? entry.remote_displayname
: entry.displayname
) || entry.id
return (
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
{avatarURL
? <img className='avatar' src={avatarURL} alt=""/>
: <NoAvatarIcon className='avatar'/>}
<span className="displayname">{displayname}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
}
class Client extends BaseMainView {
static ListEntry = ClientListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteClient
this.recovery_key = null
this.homeserverOptions = []
}
get entryKeys() {
return ["id", "displayname", "homeserver", "avatar_url", "access_token", "device_id",
"sync", "autojoin", "online", "enabled", "started", "trust_state"]
}
get initialState() {
return {
id: "",
displayname: "disable",
homeserver: "",
avatar_url: "disable",
access_token: "",
device_id: "",
fingerprint: null,
sync: true,
autojoin: true,
enabled: true,
online: true,
started: false,
trust_state: null,
instances: [],
uploadingAvatar: false,
saving: false,
deleting: false,
verifying: false,
startingOrStopping: false,
clearingCache: false,
error: "",
}
}
get clientInState() {
const client = Object.assign({}, this.state)
delete client.uploadingAvatar
delete client.saving
delete client.deleting
delete client.startingOrStopping
delete client.clearingCache
delete client.error
delete client.instances
return client
}
get selectedHomeserver() {
return this.state.homeserver
? this.homeserverEntry([this.props.ctx.homeserversByURL[this.state.homeserver],
this.state.homeserver])
: {}
}
homeserverEntry = ([serverName, serverURL]) => serverURL && {
id: serverURL,
value: serverURL,
label: serverName || serverURL,
}
componentDidUpdate(prevProps) {
this.updateHomeserverOptions()
}
updateHomeserverOptions() {
this.homeserverOptions = Object
.entries(this.props.ctx.homeserversByName)
.map(this.homeserverEntry)
}
isValidHomeserver(value) {
try {
return Boolean(new URL(value))
} catch (err) {
return false
}
}
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.clientInState)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/client/${resp.id}`)
} else {
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
startOrStop = async () => {
this.setState({ startingOrStopping: true })
const resp = await api.putClient({
id: this.props.entry.id,
started: !this.props.entry.started,
})
if (resp.id) {
this.props.onChange(resp)
this.setState({ startingOrStopping: false, error: "" })
} else {
this.setState({ startingOrStopping: false, error: resp.error })
}
}
clearCache = async () => {
this.setState({ clearingCache: true })
const resp = await api.clearClientCache(this.props.entry.id)
if (resp.success) {
this.setState({ clearingCache: false, error: "" })
} else {
this.setState({ clearingCache: false, error: resp.error })
}
}
verifyRecoveryKey = async () => {
const recoveryKey = window.prompt("Enter recovery key")
if (!recoveryKey) {
return
}
this.setState({ verifying: true })
const resp = await api.verifyClient(this.props.entry.id, recoveryKey)
if (resp.success) {
this.setState({ verifying: false, error: "" })
} else {
this.setState({ verifying: false, error: resp.error })
}
}
generateRecoveryKey = async () => {
this.setState({ verifying: true })
const resp = await api.generateRecoveryKey(this.props.entry.id)
if (resp.success) {
this.recovery_key = resp.recovery_key
this.setState({ verifying: false, error: "" })
} else {
this.setState({ verifying: false, error: resp.error })
}
}
get loading() {
return this.state.saving || this.state.startingOrStopping
|| this.clearingCache || this.state.deleting || this.state.verifying
}
renderStartedContainer = () => {
let text
if (this.props.entry.started) {
if (this.props.entry.sync_ok) {
text = "Started"
} else {
text = "Erroring"
}
} else if (this.props.entry.enabled) {
text = "Stopped"
} else {
text = "Disabled"
}
return <div className="started-container">
<span className={`started ${this.props.entry.started}
${this.props.entry.sync_ok ? "sync_ok" : "sync_error"}
${this.props.entry.enabled ? "" : "disabled"}`}/>
<span className="text">{text}</span>
</div>
}
get avatarMXC() {
if (this.state.avatar_url === "disable") {
return this.props.entry.remote_avatar_url
} else if (!this.state.avatar_url?.startsWith("mxc://")) {
return null
}
return this.state.avatar_url
}
get avatarURL() {
return api.getAvatarURL({
id: this.state.id,
avatar_url: this.avatarMXC,
})
}
renderSidebar = () => !this.isNew && (
<div className="sidebar">
<div className={`avatar-container ${this.avatarMXC ? "" : "no-avatar"}
${this.state.uploadingAvatar ? "uploading" : ""}`}>
{this.avatarMXC && <img className="avatar" src={this.avatarURL} alt="Avatar"/>}
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="image/png, image/jpeg"
onChange={this.avatarUpload} disabled={this.state.uploadingAvatar}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploadingAvatar && <Spinner/>}
</div>
{this.renderStartedContainer()}
{(this.props.entry.started || this.props.entry.enabled) && <>
<button className="save" onClick={this.startOrStop} disabled={this.loading}>
{this.state.startingOrStopping ? <Spinner/>
: (this.props.entry.started ? "Stop" : "Start")}
</button>
<button className="clearcache" onClick={this.clearCache} disabled={this.loading}>
{this.state.clearingCache ? <Spinner/> : "Clear cache"}
</button>
</>}
</div>
)
renderPreferences = () => (
<PrefTable>
<PrefInput rowName="User ID" type="text" disabled={!this.isNew} fullWidth={true}
name={this.isNew ? "id" : ""} className="id"
value={this.state.id} origValue={this.props.entry.id}
placeholder="@fancybot:example.com" onChange={this.inputChange}/>
{api.getFeatures().client_auth ? (
<PrefSelect rowName="Homeserver" options={this.homeserverOptions} fullWidth={true}
isSearchable={true} value={this.selectedHomeserver}
origValue={this.props.entry.homeserver}
onChange={({ value }) => this.setState({ homeserver: value })}
creatable={true} isValidNewOption={this.isValidHomeserver}/>
) : (
<PrefInput rowName="Homeserver" type="text" name="homeserver" fullWidth={true}
value={this.state.homeserver} origValue={this.props.entry.homeserver}
placeholder="https://example.com" onChange={this.inputChange}/>
)}
<PrefInput rowName="Access token" type="text" name="access_token"
value={this.state.access_token} origValue={this.props.entry.access_token}
placeholder="syt_bWF1Ym90_tUleVHiGyLKwXLaAMqlm_0afdcq"
onChange={this.inputChange}/>
<PrefInput rowName="Device ID" type="text" name="device_id"
value={this.state.device_id || ""}
origValue={this.props.entry.device_id || ""}
placeholder="maubot_F00BAR12" onChange={this.inputChange}/>
{this.props.entry.fingerprint && <>
<PrefInput
rowName="E2EE device fingerprint" type="text" disabled={true}
value={this.props.entry.fingerprint} className="fingerprint"
/>
<PrefInput
rowName="Trust state" type="text" disabled={true}
value={this.props.entry.trust_state || ""} className="trust-state"
/>
</>}
<PrefInput rowName="Display name" type="text" name="displayname"
value={this.state.displayname} origValue={this.props.entry.displayname}
placeholder="My fancy bot" onChange={this.inputChange}/>
<PrefInput rowName="Avatar URL" type="text" name="avatar_url"
value={this.state.avatar_url} origValue={this.props.entry.avatar_url}
placeholder="mxc://example.com/mbmwyoTvPhEQPiCskcUsppko"
onChange={this.inputChange}/>
<PrefSwitch rowName="Sync"
active={this.state.sync} origActive={this.props.entry.sync}
onToggle={sync => this.setState({ sync })}/>
<PrefSwitch rowName="Autojoin"
active={this.state.autojoin} origActive={this.props.entry.autojoin}
onToggle={autojoin => this.setState({ autojoin })}/>
<PrefSwitch rowName="Enabled"
active={this.state.enabled} origActive={this.props.entry.enabled}
onToggle={enabled => this.setState({
enabled,
started: enabled && this.state.started,
})}/>
<PrefSwitch rowName="Online"
active={this.state.online} origActive={this.props.entry.online}
onToggle={online => this.setState({ online })} />
</PrefTable>
)
renderPrefButtons = () => <>
<div className="buttons">
{!this.isNew && (
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete client that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className="save" onClick={this.save} disabled={this.loading}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
{this.renderLogButton(this.state.id)}
<div className="error">{this.state.error}</div>
</>
renderVerifyButtons = () => <>
<div className="buttons">
<button className="verify" onClick={this.verifyRecoveryKey} disabled={this.loading}>
{this.state.verifying ? <Spinner/> : "Verify"}
</button>
<button className="verify" onClick={this.generateRecoveryKey} disabled={this.loading}>
{this.state.verifying ? <Spinner/> : "Generate recovery key"}
</button>
</div>
{this.recovery_key && <div className="recovery-key">
<strong>Recovery key:</strong> <code>{this.recovery_key}</code>
</div>}
</>
render() {
return <>
<div className="client">
{this.renderSidebar()}
<div className="info">
{this.renderPreferences()}
{this.renderPrefButtons()}
{this.props.entry.fingerprint && this.renderVerifyButtons()}
{this.renderInstances()}
</div>
</div>
</>
}
}
export default withRouter(Client)

View File

@@ -0,0 +1,30 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
class Home extends Component {
render() {
return <>
<div className="home">
See sidebar to get started
</div>
<div className="buttons">
</div>
</>
}
}
export default Home

View File

@@ -0,0 +1,233 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 { Link, NavLink, Route, Switch, withRouter } from "react-router-dom"
import AceEditor from "react-ace"
import "ace-builds/src-noconflict/mode-yaml"
import "ace-builds/src-noconflict/theme-github"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as NoAvatarIcon } from "../../res/bot.svg"
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
import api from "../../api"
import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView"
import InstanceDatabase from "./InstanceDatabase"
const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
class Instance extends BaseMainView {
static ListEntry = InstanceListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deleteInstance
this.updateClientOptions()
}
get entryKeys() {
return ["id", "primary_user", "enabled", "started", "type", "config", "database_engine"]
}
get initialState() {
return {
id: "",
primary_user: "",
enabled: true,
started: true,
type: "",
config: "",
database_engine: "",
saving: false,
deleting: false,
error: "",
}
}
get instanceInState() {
const instance = Object.assign({}, this.state)
delete instance.saving
delete instance.deleting
delete instance.error
return instance
}
componentDidUpdate(prevProps) {
this.updateClientOptions()
}
getAvatarMXC(client) {
return client.avatar_url === "disable" ? client.remote_avatar_url : client.avatar_url
}
getAvatarURL(client) {
return api.getAvatarURL({
id: client.id,
avatar_url: this.getAvatarMXC(client),
})
}
clientSelectEntry = client => client && {
id: client.id,
value: client.id,
label: (
<div className="select-client">
{this.getAvatarMXC(client)
? <img className="avatar" src={this.getAvatarURL(client)} alt=""/>
: <NoAvatarIcon className='avatar'/>}
<span className="displayname">{
(client.displayname === "disable"
? client.remote_displayname
: client.displayname
) || client.id
}</span>
</div>
),
}
updateClientOptions() {
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
}
save = async () => {
this.setState({ saving: true })
const resp = await api.putInstance(this.instanceInState, this.props.entry
? this.props.entry.id : undefined)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/instance/${resp.id}`)
} else {
if (resp.id !== this.props.entry.id) {
this.props.history.replace(`/instance/${resp.id}`)
}
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
get selectedClientEntry() {
return this.state.primary_user
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])
: {}
}
get selectedPluginEntry() {
return {
id: this.state.type,
value: this.state.type,
label: this.state.type,
}
}
get typeOptions() {
return Object.values(this.props.ctx.plugins).map(plugin => plugin && {
id: plugin.id,
value: plugin.id,
label: plugin.id,
})
}
get loading() {
return this.state.deleting || this.state.saving
}
get isValid() {
return this.state.id && this.state.primary_user && this.state.type
}
render() {
return <Switch>
<Route path="/instance/:id/database" render={this.renderDatabase}/>
<Route render={this.renderMain}/>
</Switch>
}
renderDatabase = () => <InstanceDatabase instanceID={this.props.entry.id}/>
renderMain = () => <div className="instance">
<PrefTable>
<PrefInput rowName="ID" type="text" name="id" value={this.state.id}
placeholder="fancybotinstance" onChange={this.inputChange}
disabled={!this.isNew} fullWidth={true} className="id"/>
<PrefSwitch rowName="Enabled"
active={this.state.enabled} origActive={this.props.entry.enabled}
onToggle={enabled => this.setState({ enabled })}/>
<PrefSwitch rowName="Running"
active={this.state.started} origActive={this.props.entry.started}
onToggle={started => this.setState({ started })}/>
{api.getFeatures().client ? (
<PrefSelect rowName="Primary user" options={this.clientOptions}
isSearchable={false} value={this.selectedClientEntry}
origValue={this.props.entry.primary_user}
onChange={({ id }) => this.setState({ primary_user: id })}/>
) : (
<PrefInput rowName="Primary user" type="text" name="primary_user"
value={this.state.primary_user} placeholder="@user:example.com"
onChange={this.inputChange}/>
)}
{api.getFeatures().plugin ? (
<PrefSelect rowName="Type" options={this.typeOptions} isSearchable={false}
value={this.selectedPluginEntry} origValue={this.props.entry.type}
onChange={({ id }) => this.setState({ type: id })}/>
) : (
<PrefInput rowName="Type" type="text" name="type" value={this.state.type}
placeholder="xyz.maubot.example" onChange={this.inputChange}/>
)}
</PrefTable>
{!this.isNew && Boolean(this.props.entry.base_config) &&
<AceEditor mode="yaml" theme="github" onChange={config => this.setState({ config })}
name="config" value={this.state.config}
editorProps={{
fontSize: "10pt",
$blockScrolling: true,
}}/>}
<div className="buttons">
{!this.isNew && (
<button className="delete" onClick={this.delete} disabled={this.loading}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
)}
<button className={`save ${this.isValid ? "" : "disabled-bg"}`}
onClick={this.save} disabled={this.loading || !this.isValid}>
{this.state.saving ? <Spinner/> : (this.isNew ? "Create" : "Save")}
</button>
</div>
{!this.isNew && <div className="buttons">
{this.props.entry.database && (
<Link className="button open-database"
to={`/instance/${this.state.id}/database`}>
View database
</Link>
)}
{api.getFeatures().log &&
<button className="open-log"
onClick={() => this.props.openLog(`instance.${this.state.id}`)}>
View logs
</button>}
</div>}
<div className="error">{this.state.error}</div>
</div>
}
export default withRouter(Instance)

View File

@@ -0,0 +1,332 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
import { NavLink, Link, withRouter } from "react-router-dom"
import { ContextMenu, ContextMenuTrigger, MenuItem } from "react-contextmenu"
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api"
import Spinner from "../../components/Spinner"
// eslint-disable-next-line no-extend-native
Map.prototype.map = function(func) {
const res = []
for (const [key, value] of this) {
res.push(func(value, key, this))
}
return res
}
class InstanceDatabase extends Component {
constructor(props) {
super(props)
this.state = {
tables: null,
header: null,
content: null,
query: "",
selectedTable: null,
error: null,
prevQuery: null,
statusMsg: null,
rowCount: null,
insertedPrimaryKey: null,
}
this.order = new Map()
}
async componentDidMount() {
const tables = new Map(Object.entries(await api.getInstanceDatabase(this.props.instanceID)))
for (const [name, table] of tables) {
table.name = name
table.columns = new Map(Object.entries(table.columns))
for (const [columnName, column] of table.columns) {
column.name = columnName
column.sort = null
}
}
this.setState({ tables })
this.checkLocationTable()
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.order = new Map()
this.setState({ header: null, content: null })
this.checkLocationTable()
}
}
checkLocationTable() {
const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length)
this.setState({ selectedTable: table })
this.buildSQLQuery(table)
}
}
getSortQueryParams(table) {
const order = []
for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
order.push(`order=${column}:${sort}`)
}
return order
}
buildSQLQuery(table = this.state.selectedTable, resetContent = true) {
let query = `SELECT * FROM "${table}"`
if (this.order.size > 0) {
const order = Array.from(this.order.entries()).reverse()
.map(([column, sort]) => `${column} ${sort}`)
query += ` ORDER BY ${order.join(", ")}`
}
query += " LIMIT 100"
this.setState({ query }, () => this.reloadContent(resetContent))
}
reloadContent = async (resetContent = true) => {
this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({ loading: false })
if (resetContent) {
this.setState({
prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
statusMsg: null,
error: null,
})
}
if (!res.ok) {
this.setState({
error: res.error,
})
} else if (res.rows) {
this.setState({
header: res.columns,
content: res.rows,
})
} else {
this.setState({
prevQuery: res.query,
rowCount: res.rowcount,
insertedPrimaryKey: res.inserted_primary_key,
statusMsg: res.status_msg,
})
this.buildSQLQuery(this.state.selectedTable, false)
}
}
toggleSort(column) {
const oldSort = this.order.get(column) || "auto"
this.order.delete(column)
switch (oldSort) {
case "auto":
this.order.set(column, "DESC")
break
case "DESC":
this.order.set(column, "ASC")
break
case "ASC":
default:
break
}
this.buildSQLQuery()
}
getSortIcon(column) {
switch (this.order.get(column)) {
case "DESC":
return <OrderDesc/>
case "ASC":
return <OrderAsc/>
default:
return null
}
}
getColumnInfo(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
if (column.primary) {
return <span className="meta">&nbsp;(pk)</span>
} else if (column.unique) {
return <span className="meta">&nbsp;(u)</span>
}
return null
}
getColumnType(columnName) {
const table = this.state.tables.get(this.state.selectedTable)
if (!table) {
return null
}
const column = table.columns.get(columnName)
if (!column) {
return null
}
return column.type
}
deleteRow = async (_, data) => {
const values = this.state.content[data.row]
const keys = this.state.header
const condition = []
for (const [index, key] of Object.entries(keys)) {
const val = values[index]
condition.push(`${key}='${this.sqlEscape(val.toString())}'`)
}
const query = `DELETE FROM "${this.state.selectedTable}" WHERE ${condition.join(" AND ")}`
const res = await api.queryInstanceDatabase(this.props.instanceID, query)
this.setState({
prevQuery: `DELETE FROM "${this.state.selectedTable}" ...`,
rowCount: res.rowcount,
})
await this.reloadContent(false)
}
editCell = async (evt, data) => {
console.log("Edit", data)
}
collectContextMeta = props => ({
row: props.row,
col: props.col,
})
// eslint-disable-next-line no-control-regex
sqlEscape = str => str.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, char => {
switch (char) {
case "\0":
return "\\0"
case "\x08":
return "\\b"
case "\x09":
return "\\t"
case "\x1a":
return "\\z"
case "\n":
return "\\n"
case "\r":
return "\\r"
case "\"":
case "'":
case "\\":
case "%":
return "\\" + char
default:
return char
}
})
renderTable = () => <div className="table">
{this.state.header ? <>
<table>
<thead>
<tr>
{this.state.header.map(column => (
<td key={column}>
<span onClick={() => this.toggleSort(column)}
title={this.getColumnType(column)}>
<strong>{column}</strong>
{this.getColumnInfo(column)}
{this.getSortIcon(column)}
</span>
</td>
))}
</tr>
</thead>
<tbody>
{this.state.content.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, colIndex) => (
<ContextMenuTrigger key={colIndex} id="database_table_menu"
renderTag="td" row={rowIndex} col={colIndex}
collect={this.collectContextMeta}>
{cell}
</ContextMenuTrigger>
))}
</tr>
))}
</tbody>
</table>
<ContextMenu id="database_table_menu">
<MenuItem onClick={this.deleteRow}>Delete row</MenuItem>
<MenuItem disabled onClick={this.editCell}>Edit cell</MenuItem>
</ContextMenu>
</> : this.state.loading ? <Spinner/> : null}
</div>
renderContent() {
return <>
<div className="tables">
{this.state.tables.map((_, tbl) => (
<NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
{tbl}
</NavLink>
))}
</div>
<div className="query">
<input type="text" value={this.state.query} name="query"
onChange={evt => this.setState({ query: evt.target.value })}/>
<button type="submit" onClick={this.reloadContent}>Query</button>
</div>
{this.state.error && <div className="error">
{this.state.error}
</div>}
{this.state.prevQuery && <div className="prev-query">
<p>
Executed <span className="query">{this.state.prevQuery}</span> - {
this.state.statusMsg
|| <>affected <strong>{this.state.rowCount} rows</strong>.</>
}
</p>
{this.state.insertedPrimaryKey && <p className="inserted-primary-key">
Inserted primary key: {this.state.insertedPrimaryKey}
</p>}
</div>}
{this.renderTable()}
</>
}
render() {
return <div className="instance-database">
<div className="topbar">
<Link className="topbar" to={`/instance/${this.props.instanceID}`}>
<ChevronLeft/>
Back
</Link>
</div>
{this.state.tables
? this.renderContent()
: <Spinner className="maubot-loading"/>}
</div>
}
}
export default withRouter(InstanceDatabase)

View File

@@ -0,0 +1,191 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { PureComponent } from "react"
import { Link } from "react-router-dom"
import { JSONTree } from "react-json-tree"
import api from "../../api"
import Modal from "./Modal"
class LogEntry extends PureComponent {
static contextType = Modal.Context
renderName() {
const line = this.props.line
if (line.nameLink) {
const modal = this.context
return <>
<Link to={line.nameLink} onClick={modal.close}>
{line.name}
</Link>
{line.nameSuffix}
</>
}
return line.name
}
renderContent() {
if (this.props.line.matrix_http_request) {
const req = this.props.line.matrix_http_request
return <>
{req.method} {req.url || req.path}
<div className="content">
{Object.entries(req.content || {}).length > 0
&& <JSONTree data={{ content: req.content }} hideRoot={true}/>}
</div>
</>
}
return this.props.line.msg
}
onClickOpen(path, line) {
return () => {
if (api.debugOpenFileEnabled()) {
api.debugOpenFile(path, line)
}
return false
}
}
renderTimeTitle() {
return this.props.line.time.toDateString()
}
renderTime() {
return <a className="time" title={this.renderTimeTitle()}
href={`file:///${this.props.line.pathname}:${this.props.line.lineno}`}
onClick={this.onClickOpen(this.props.line.pathname, this.props.line.lineno)}>
{this.props.line.time.toLocaleTimeString("en-GB")}
</a>
}
renderLevelName() {
return <span className="level">
{this.props.line.levelname}
</span>
}
get unfocused() {
return this.props.focus && this.props.line.name !== this.props.focus
? "unfocused"
: ""
}
renderRow(content) {
return (
<div className={`row ${this.props.line.levelname.toLowerCase()} ${this.unfocused}`}>
{this.renderTime()}
{this.renderLevelName()}
<span className="logger">{this.renderName()}</span>
<span className="text">{content}</span>
</div>
)
}
renderExceptionInfo() {
if (!api.debugOpenFileEnabled()) {
return this.props.line.exc_info
}
const fileLinks = []
let str = this.props.line.exc_info.replace(
/File "(.+)", line ([0-9]+), in (.+)/g,
(_, file, line, method) => {
fileLinks.push(
<a href={`file:///${file}:${line}`} onClick={this.onClickOpen(file, line)}>
File "{file}", line {line}, in {method}
</a>,
)
return "||EDGE||"
})
fileLinks.reverse()
const result = []
let key = 0
for (const part of str.split("||EDGE||")) {
result.push(<React.Fragment key={key++}>
{part}
{fileLinks.pop()}
</React.Fragment>)
}
return result
}
render() {
return <>
{this.renderRow(this.renderContent())}
{this.props.line.exc_info && this.renderRow(this.renderExceptionInfo())}
</>
}
}
class Log extends PureComponent {
constructor(props) {
super(props)
this.linesRef = React.createRef()
this.linesBottomRef = React.createRef()
}
getSnapshotBeforeUpdate() {
if (this.linesRef.current && this.linesBottomRef.current) {
return Log.isVisible(this.linesRef.current, this.linesBottomRef.current)
}
return false
}
componentDidUpdate(_1, _2, wasVisible) {
if (wasVisible) {
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
}
}
componentDidMount() {
if (this.linesRef.current && this.linesBottomRef.current) {
Log.scrollParentToChild(this.linesRef.current, this.linesBottomRef.current)
}
}
static scrollParentToChild(parent, child) {
const parentRect = parent.getBoundingClientRect()
const childRect = child.getBoundingClientRect()
if (!Log.isVisible(parent, child)) {
parent.scrollBy({ top: (childRect.top + parent.scrollTop) - parentRect.top })
}
}
static isVisible(parent, child) {
const parentRect = parent.getBoundingClientRect()
const childRect = child.getBoundingClientRect()
return (childRect.top >= parentRect.top)
&& (childRect.top <= parentRect.top + parent.clientHeight)
}
render() {
return (
<div className="log" ref={this.linesRef}>
<div className="lines">
{this.props.lines.map(data => <LogEntry key={data.id} line={data}
focus={this.props.focus}/>)}
</div>
<div ref={this.linesBottomRef}/>
</div>
)
}
}
export default Log

View File

@@ -0,0 +1,52 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component, createContext } from "react"
const rem = 16
class Modal extends Component {
static Context = createContext(null)
constructor(props) {
super(props)
this.state = {
open: false,
}
this.wrapper = { clientWidth: 9001 }
}
open = () => this.setState({ open: true })
close = () => this.setState({ open: false })
isOpen = () => this.state.open
render() {
return this.state.open && (
<div className="modal-wrapper-wrapper" ref={ref => this.wrapper = ref}
onClick={() => this.wrapper.clientWidth > 45 * rem && this.close()}>
<div className="modal-wrapper" onClick={evt => evt.stopPropagation()}>
<button className="close" onClick={this.close}>Close</button>
<div className="modal">
<Modal.Context.Provider value={this}>
{this.props.children}
</Modal.Context.Provider>
</div>
</div>
</div>
)
}
}
export default Modal

View File

@@ -0,0 +1,104 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const PluginListEntry = ({ entry }) => (
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight className='chevron'/>
</NavLink>
)
class Plugin extends BaseMainView {
static ListEntry = PluginListEntry
constructor(props) {
super(props)
this.deleteFunc = api.deletePlugin
}
get initialState() {
return {
id: "",
version: "",
instances: [],
uploading: false,
deleting: false,
error: "",
}
}
upload = async event => {
const file = event.target.files[0]
this.setState({
uploadingAvatar: true,
})
const data = await this.readFile(file)
const resp = await api.uploadPlugin(data, this.state.id)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/plugin/${resp.id}`)
} else {
this.setState({ saving: false, error: "" })
}
this.props.onChange(resp)
} else {
this.setState({ saving: false, error: resp.error })
}
}
render() {
return <div className="plugin">
{!this.isNew && <PrefTable>
<PrefInput rowName="ID" type="text" value={this.state.id} disabled={true}
className="id"/>
<PrefInput rowName="Version" type="text" value={this.state.version}
disabled={true}/>
</PrefTable>}
{api.getFeatures().plugin_upload &&
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>
<UploadButton className="upload"/>
<input className="file-selector" type="file" accept="application/zip+mbp"
onChange={this.upload} disabled={this.state.uploading || this.state.deleting}
onDragEnter={evt => evt.target.parentElement.classList.add("drag")}
onDragLeave={evt => evt.target.parentElement.classList.remove("drag")}/>
{this.state.uploading && <Spinner/>}
</div>}
{!this.isNew && <div className="buttons">
<button className={`delete ${this.hasInstances ? "disabled-bg" : ""}`}
onClick={this.delete} disabled={this.loading || this.hasInstances}
title={this.hasInstances ? "Can't delete plugin that is in use" : ""}>
{this.state.deleting ? <Spinner/> : "Delete"}
</button>
</div>}
{this.renderLogButton("loader.zip")}
<div className="error">{this.state.error}</div>
{this.renderInstances()}
</div>
}
}
export default withRouter(Plugin)

View File

@@ -0,0 +1,272 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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, { Component } from "react"
import { Route, Switch, Link, withRouter } from "react-router-dom"
import api from "../../api"
import { ReactComponent as Plus } from "../../res/plus.svg"
import Instance from "./Instance"
import Client from "./Client"
import Plugin from "./Plugin"
import Home from "./Home"
import Log from "./Log"
import Modal from "./Modal"
class Dashboard extends Component {
constructor(props) {
super(props)
this.state = {
instances: {},
clients: {},
plugins: {},
homeserversByName: {},
homeserversByURL: {},
sidebarOpen: false,
modalOpen: false,
logFocus: null,
logLines: [],
}
this.logModal = {
open: () => undefined,
isOpen: () => false,
}
window.maubot = this
}
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.setState({ sidebarOpen: false })
}
}
async componentDidMount() {
const [instanceList, clientList, pluginList, homeservers] = await Promise.all([
api.getInstances(), api.getClients(), api.getPlugins(), api.getClientAuthServers(),
api.updateDebugOpenFileEnabled()])
const instances = {}
if (api.getFeatures().instance) {
for (const instance of instanceList) {
instances[instance.id] = instance
}
}
const clients = {}
if (api.getFeatures().client) {
for (const client of clientList) {
clients[client.id] = client
}
}
const plugins = {}
if (api.getFeatures().plugin) {
for (const plugin of pluginList) {
plugins[plugin.id] = plugin
}
}
const homeserversByName = homeservers
const homeserversByURL = {}
if (api.getFeatures().client_auth) {
for (const [key, value] of Object.entries(homeservers)) {
homeserversByURL[value] = key
}
}
this.setState({ instances, clients, plugins, homeserversByName, homeserversByURL })
await this.enableLogs()
}
async enableLogs() {
if (!api.getFeatures().log) {
return
}
const logs = await api.openLogSocket()
const processEntry = (entry) => {
entry.time = new Date(entry.time)
entry.nameSuffix = ""
if (entry.name.startsWith("maubot.client.")) {
if (entry.name.endsWith(".crypto")) {
entry.name = entry.name.slice(0, -".crypto".length)
entry.nameSuffix = "/crypto"
}
entry.name = `client/${entry.name.slice("maubot.client.".length)}`
entry.nameLink = `/${entry.name}`
} else if (entry.name.startsWith("maubot.instance.")) {
entry.name = `instance/${entry.name.slice("maubot.instance.".length)}`
entry.nameLink = `/${entry.name}`
} else if (entry.name.startsWith("maubot.instance_db.")) {
entry.nameSuffix = "/db"
entry.name = `instance/${entry.name.slice("maubot.instance_db.".length)}`
entry.nameLink = `/${entry.name}`
}
}
logs.onHistory = history => {
for (const data of history) {
processEntry(data)
}
this.setState({
logLines: history,
})
}
logs.onLog = data => {
processEntry(data)
this.setState({
logLines: this.state.logLines.concat(data),
})
}
}
renderList(field, type) {
return this.state[field] && Object.values(this.state[field]).map(entry =>
React.createElement(type, { key: entry.id, entry }))
}
delete(stateField, id) {
const data = Object.assign({}, this.state[stateField])
delete data[id]
this.setState({ [stateField]: data })
}
add(stateField, entry, oldID = undefined) {
const data = Object.assign({}, this.state[stateField])
if (oldID && oldID !== entry.id) {
delete data[oldID]
}
data[entry.id] = entry
this.setState({ [stateField]: data })
}
renderView(field, type, id) {
const typeName = field.slice(0, -1)
if (!api.getFeatures()[typeName]) {
return this.renderDisabled(typeName)
}
const entry = this.state[field][id]
if (!entry) {
return this.renderNotFound(typeName)
}
return React.createElement(type, {
entry,
onDelete: () => this.delete(field, id),
onChange: newEntry => this.add(field, newEntry, id),
openLog: this.openLog,
ctx: this.state,
})
}
openLog = filter => {
this.setState({
logFocus: typeof filter === "string" ? filter : null,
})
this.logModal.open()
}
renderNotFound = (thing = "path") => (
<div className="not-found">
Oops! I'm afraid that {thing} couldn't be found.
</div>
)
renderDisabled = (thing = "path") => (
<div className="not-found">
The {thing} API has been disabled in the maubot config.
</div>
)
renderMain() {
return <div className={`dashboard ${this.state.sidebarOpen ? "sidebar-open" : ""}`}>
<Link to="/" className="title">
<img src="favicon.png" alt=""/>
Maubot Manager
</Link>
<div className="user">
<span>{localStorage.username}</span>
</div>
<nav className="sidebar">
<div className="buttons">
{api.getFeatures().log && <button className="open-log" onClick={this.openLog}>
<span>View logs</span>
</button>}
</div>
{api.getFeatures().instance && <div className="instances list">
<div className="title">
<h2>Instances</h2>
<Link to="/new/instance"><Plus/></Link>
</div>
{this.renderList("instances", Instance.ListEntry)}
</div>}
{api.getFeatures().client && <div className="clients list">
<div className="title">
<h2>Clients</h2>
<Link to="/new/client"><Plus/></Link>
</div>
{this.renderList("clients", Client.ListEntry)}
</div>}
{api.getFeatures().plugin && <div className="plugins list">
<div className="title">
<h2>Plugins</h2>
{api.getFeatures().plugin_upload && <Link to="/new/plugin"><Plus/></Link>}
</div>
{this.renderList("plugins", Plugin.ListEntry)}
</div>}
</nav>
<div className="topbar"
onClick={evt => this.setState({ sidebarOpen: !this.state.sidebarOpen })}>
<div className={`hamburger ${this.state.sidebarOpen ? "active" : ""}`}>
<span/><span/><span/>
</div>
</div>
<main className="view">
<Switch>
<Route path="/" exact render={() => <Home openLog={this.openLog}/>}/>
<Route path="/new/instance" render={() =>
<Instance onChange={newEntry => this.add("instances", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/new/client" render={() =>
<Client onChange={newEntry => this.add("clients", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/new/plugin" render={() =>
<Plugin onChange={newEntry => this.add("plugins", newEntry)}
entry={{}} ctx={this.state}/>}/>
<Route path="/instance/:id" render={({ match }) =>
this.renderView("instances", Instance, match.params.id)}/>
<Route path="/client/:id" render={({ match }) =>
this.renderView("clients", Client, match.params.id)}/>
<Route path="/plugin/:id" render={({ match }) =>
this.renderView("plugins", Plugin, match.params.id)}/>
<Route render={() => this.renderNotFound()}/>
</Switch>
</main>
</div>
}
renderModal() {
return <Modal ref={ref => this.logModal = ref}>
<Log lines={this.state.logLines} focus={this.state.logFocus}/>
</Modal>
}
render() {
return <>
{this.renderMain()}
{this.renderModal()}
</>
}
}
export default withRouter(Dashboard)

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13Z" />
</svg>

After

Width:  |  Height:  |  Size: 753 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 184 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 185 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M17,13H13V17H11V13H7V11H11V7H13V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>

After

Width:  |  Height:  |  Size: 432 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7 14l5-5 5 5z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 153 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24">
<path fill="#000000" d="M9,16V10H5L12,3L19,10H15V16H9M5,20V18H19V20H5Z" />
</svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1,6 @@
const proxy = require("http-proxy-middleware")
module.exports = function(app) {
app.use(proxy("/_matrix/maubot/v1", { target: process.env.PROXY || "http://localhost:29316" }))
app.use(proxy("/_matrix/maubot/v1/logs", { target: process.env.PROXY || "http://localhost:29316", ws: true }))
}

View File

@@ -0,0 +1,39 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
body
font-family: $font-stack
margin: 0
padding: 0
font-size: 16px
#root
position: fixed
top: 0
bottom: 0
right: 0
left: 0
.maubot-wrapper
position: absolute
top: 0
bottom: 0
left: 0
right: 0
background-color: $background-dark
.maubot-loading
margin-top: 10rem
width: 10rem

View File

@@ -0,0 +1,120 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
=button($width: null, $height: null, $padding: .375rem 1rem)
font-family: $font-stack
padding: $padding
width: $width
height: $height
background-color: $background
border: none
border-radius: .25rem
color: $text-color
box-sizing: border-box
font-size: 1rem
&.disabled-bg
background-color: $background-dark
&:not(:disabled)
cursor: pointer
&:hover
background-color: darken($background, 10%)
=link-button()
display: inline-block
text-align: center
text-decoration: none
=main-color-button()
background-color: $primary
color: $inverted-text-color
&:hover:not(:disabled)
background-color: $primary-dark
&:disabled.disabled-bg
background-color: $background-dark !important
color: $text-color
.button
+button
&.main-color
+main-color-button
=button-group()
width: 100%
display: flex
> button, > .button
flex: 1
&:first-child
margin-right: .5rem
&:last-child
margin-left: .5rem
&:first-child:last-child
margin: 0
=vertical-button-group()
display: flex
flex-direction: column
> button, > .button
flex: 1
border-radius: 0
&:first-of-type
border-radius: .25rem .25rem 0 0
&:last-of-type
border-radius: 0 0 .25rem .25rem
&:first-of-type:last-of-type
border-radius: .25rem
=input($width: null, $height: null, $vertical-padding: .375rem, $horizontal-padding: 1rem, $font-size: 1rem)
font-family: $font-stack
border: 1px solid $border-color
background-color: $background
color: $text-color
width: $width
height: $height
box-sizing: border-box
border-radius: .25rem
padding: $vertical-padding $horizontal-padding
font-size: $font-size
resize: vertical
&:hover, &:focus
border-color: $primary
&:focus
border-width: 2px
padding: calc(#{$vertical-padding} - 1px) calc(#{$horizontal-padding} - 1px)
.input, .textarea
+input
input
font-family: $font-stack
=notification($border: $error-dark, $background: transparentize($error-light, 0.5))
padding: 1rem
border-radius: .25rem
border: 2px solid $border
background-color: $background

View File

@@ -0,0 +1,30 @@
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 300
src: local('Raleway Light'), local('Raleway-Light'), url('../../fonts/raleway-light.woff2') format('woff2')
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 400
src: local('Raleway'), local('Raleway-Regular'), url('../../fonts/raleway-regular.woff2') format('woff2')
@font-face
font-family: 'Raleway'
font-style: normal
font-weight: 700
src: local('Raleway Bold'), local('Raleway-Bold'), url('../../fonts/raleway-bold.woff2') format('woff2')
@font-face
font-family: 'Fira Code'
src: url('../../fonts/firacode-regular.woff2') format('woff2')
font-weight: 400
font-style: normal
@font-face
font-family: 'Fira Code'
src: url('../../fonts/firacode-bold.woff2') format('woff2')
font-weight: 700
font-style: normal

View File

@@ -0,0 +1,33 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
$primary: #00C853
$primary-dark: #009624
$primary-light: #5EFC82
$secondary: #00B8D4
$secondary-dark: #0088A3
$secondary-light: #62EBFF
$error: #B71C1C
$error-dark: #7F0000
$error-light: #F05545
$warning: orange
$border-color: #DDD
$text-color: #212121
$background: #FAFAFA
$background-dark: #E7E7E7
$inverted-text-color: $background
$font-stack: Raleway, sans-serif

View File

@@ -0,0 +1,33 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 lib/spinner
@import lib/contextmenu
@import base/fonts
@import base/vars
@import base/body
@import base/elements
@import lib/preferencetable
@import lib/switch
@import pages/mixins/upload-container
@import pages/mixins/instancelist
@import pages/login
@import pages/dashboard
@import pages/modal
@import pages/log

View File

@@ -0,0 +1,80 @@
.react-contextmenu {
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, .15);
border-radius: .25rem;
color: #373a3c;
font-size: 16px;
margin: 2px 0 0;
min-width: 160px;
outline: none;
opacity: 0;
padding: 5px 0;
pointer-events: none;
text-align: left;
transition: opacity 250ms ease !important;
}
.react-contextmenu.react-contextmenu--visible {
opacity: 1;
pointer-events: auto;
z-index: 9999;
}
.react-contextmenu-item {
background: 0 0;
border: 0;
color: #373a3c;
cursor: pointer;
font-weight: 400;
line-height: 1.5;
padding: 3px 20px;
text-align: inherit;
white-space: nowrap;
}
.react-contextmenu-item.react-contextmenu-item--active,
.react-contextmenu-item.react-contextmenu-item--selected {
color: #fff;
background-color: #20a0ff;
border-color: #20a0ff;
text-decoration: none;
}
.react-contextmenu-item.react-contextmenu-item--disabled,
.react-contextmenu-item.react-contextmenu-item--disabled:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
color: #878a8c;
}
.react-contextmenu-item--divider {
border-bottom: 1px solid rgba(0, 0, 0, .15);
cursor: inherit;
margin-bottom: 3px;
padding: 2px 0;
}
.react-contextmenu-item--divider:hover {
background-color: transparent;
border-color: rgba(0, 0, 0, .15);
}
.react-contextmenu-item.react-contextmenu-submenu {
padding: 0;
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item {
}
.react-contextmenu-item.react-contextmenu-submenu > .react-contextmenu-item:after {
content: "";
display: inline-block;
position: absolute;
right: 7px;
}
.example-multiple-targets::after {
content: attr(data-count);
display: block;
}

View File

@@ -0,0 +1,85 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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: flex
width: 100%
flex-wrap: wrap
> .entry
display: block
@media screen and (max-width: 55rem)
width: calc(100% - 1rem)
width: calc(50% - 1rem)
margin: .5rem
&.full-width
width: 100%
&.changed > label
font-weight: bold
&:after
content: "*"
> label, > .value
display: block
width: 100%
> label
font-size: 0.875rem
padding-bottom: .25rem
font-weight: lighter
> .value
> .switch
width: auto
height: 2rem
> .select
height: 2.5rem
box-sizing: border-box
> input
border: none
height: 2rem
width: 100%
color: $text-color
box-sizing: border-box
padding: .375rem 0 calc(.375rem + 1px)
background-color: $background
font-size: 1rem
border-bottom: 1px solid $background
&.id:disabled
font-family: "Fira Code", monospace
font-weight: bold
&:not(:disabled)
border-bottom: 1px dotted $primary
&:hover
border-bottom: 1px solid $primary
&:focus
border-bottom: 2px solid $primary
padding-bottom: .375rem

View File

@@ -0,0 +1,65 @@
$green: #008744
$blue: #0057e7
$red: #d62d20
$yellow: #ffa700
.spinner
position: relative
margin: 0 auto
width: 5rem
&:before
content: ""
display: block
padding-top: 100%
svg
animation: rotate 2s linear infinite
height: 100%
transform-origin: center center
width: 100%
position: absolute
top: 0
bottom: 0
left: 0
right: 0
margin: auto
circle
stroke-dasharray: 1, 200
stroke-dashoffset: 0
animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite
stroke-linecap: round
=white-spinner()
circle
stroke: white !important
=thick-spinner($thickness: 5)
svg > circle
stroke-width: $thickness
@keyframes rotate
100%
transform: rotate(360deg)
@keyframes dash
0%
stroke-dasharray: 1, 200
stroke-dashoffset: 0
50%
stroke-dasharray: 89, 200
stroke-dashoffset: -35px
100%
stroke-dasharray: 89, 200
stroke-dashoffset: -124px
@keyframes color
100%, 0%
stroke: $red
40%
stroke: $blue
66%
stroke: $green
80%, 90%
stroke: $yellow

View File

@@ -0,0 +1,77 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
.switch
display: flex
width: 100%
height: 2rem
cursor: pointer
border: 1px solid $error-light
border-radius: .25rem
background-color: $background
box-sizing: border-box
> .box
display: flex
box-sizing: border-box
width: 50%
height: 100%
transition: .5s
text-align: center
border-radius: .15rem 0 0 .15rem
background-color: $error-light
color: $inverted-text-color
align-items: center
> .text
box-sizing: border-box
width: 100%
text-align: center
vertical-align: middle
color: $inverted-text-color
font-size: 1rem
user-select: none
.on
display: none
.off
display: inline
&[data-active=true]
border: 1px solid $primary
> .box
background-color: $primary
transform: translateX(100%)
border-radius: 0 .15rem .15rem 0
.on
display: inline
.off
display: none

View File

@@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> div.avatar-container
+upload-box
width: 8rem
height: 8rem
border-radius: 50%
@media screen and (max-width: 40rem)
margin: 0 auto 1rem
> img.avatar
position: absolute
display: block
max-width: 8rem
max-height: 8rem
user-select: none
> svg.upload
visibility: hidden
width: 6rem
height: 6rem
> input.file-selector
width: 8rem
height: 8rem
&:not(.uploading)
&:hover, &.drag
> 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

View File

@@ -0,0 +1,48 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> div.client
display: flex
> div.sidebar
vertical-align: top
text-align: center
width: 8rem
margin-right: 1rem
> *
margin-bottom: 1rem
@import avatar
@import started
> div.info
vertical-align: top
flex: 1
> div.instances
+instancelist
input.fingerprint
font-family: "Fira Code", monospace
font-size: 0.8em
@media screen and (max-width: 40rem)
flex-wrap: wrap
> div.sidebar, > div.info
width: 100%
margin-right: 0

View File

@@ -0,0 +1,46 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> div.started-container
display: inline-flex
> span.started
display: inline-block
height: 0
width: 0
border-radius: 50%
margin: .5rem
&.true
&.sync_ok
background-color: $primary
box-shadow: 0 0 .75rem .75rem $primary
&.sync_error
background-color: $warning
box-shadow: 0 0 .75rem .75rem $warning
&.false
background-color: $error-light
box-shadow: 0 0 .75rem .75rem $error-light
&.disabled
background-color: $border-color
box-shadow: 0 0 .75rem .75rem $border-color
> span.text
display: inline-block
margin-left: 1rem

View File

@@ -0,0 +1,19 @@
.dashboard {
grid-template:
[row1-start] "title main" 3.5rem [row1-end]
[row2-start] "user main" 2.5rem [row2-end]
[row3-start] "sidebar main" auto [row3-end]
/ 15rem auto;
}
@media screen and (max-width: 35rem) {
.dashboard {
grid-template:
[row1-start] "title topbar" 3.5rem [row1-end]
[row2-start] "user main" 2.5rem [row2-end]
[row3-start] "sidebar main" auto [row3-end]
/ 15rem 100%;
overflow-x: hidden;
}
}

View File

@@ -0,0 +1,129 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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 "dashboard-grid"
.dashboard
display: grid
height: 100%
max-width: 60rem
margin: auto
box-shadow: 0 .5rem .5rem rgba(0, 0, 0, 0.5)
background-color: $background
> a.title
grid-area: title
background-color: white
display: flex
align-items: center
justify-content: center
font-size: 1.35rem
font-weight: bold
color: $text-color
text-decoration: none
> img
max-width: 2rem
margin-right: .5rem
> div.user
grid-area: user
background-color: white
border-bottom: 1px solid $border-color
display: flex
align-items: center
justify-content: center
span
display: flex
align-items: center
justify-content: center
background-color: $primary
color: $inverted-text-color
margin: .375rem .5rem
width: 100%
height: calc(100% - .375rem)
box-sizing: border-box
border-radius: .25rem
@import sidebar
@import topbar
@media screen and (max-width: 35rem)
&:not(.sidebar-open) > *
transform: translateX(-15rem)
> *
transition: transform 0.4s
> main.view
grid-area: main
border-left: 1px solid $border-color
overflow-y: auto
@import client/index
@import instance
@import instance-database
@import plugin
> div
margin: 2rem 4rem
@media screen and (max-width: 50rem)
margin: 1rem
> div.not-found, > div.home
text-align: center
margin-top: 5rem
font-size: 1.5rem
div.buttons
+button-group
display: flex
margin: 1rem .5rem
width: calc(100% - 1rem)
button.open-log, a.open-database
+button
+main-color-button
a.open-database
+link-button
div.error
+notification($error)
margin: 1rem .5rem
&:empty
display: none
button.delete
background-color: $error-light !important
&:hover
background-color: $error !important
button.save, button.clearcache, button.delete, button.verify
+button
+main-color-button
width: 100%
height: 2.5rem
padding: 0
> .spinner
+thick-spinner
+white-spinner
width: 2rem

View File

@@ -0,0 +1,101 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> div.instance-database
margin: 0
> div.topbar
background-color: $primary-light
display: flex
justify-items: center
align-items: center
> a
display: flex
justify-items: center
align-items: center
text-decoration: none
user-select: none
height: 2.5rem
width: 100%
> *:not(.topbar)
margin: 2rem 4rem
@media screen and (max-width: 50rem)
margin: 1rem
> div.tables
display: flex
flex-wrap: wrap
> a
+link-button
color: black
flex: 1
border-bottom: 2px solid $primary
padding: .25rem
margin: .25rem
&:hover
background-color: $primary-light
border-bottom: 2px solid $primary-dark
&.active
background-color: $primary
> div.query
display: flex
> input
+input
font-family: "Fira Code", monospace
flex: 1
margin-right: .5rem
> button
+button
+main-color-button
> div.prev-query
+notification($primary, $primary-light)
span.query
font-family: "Fira Code", monospace
p
margin: 0
> div.table
overflow-x: auto
overflow-y: hidden
table
font-family: "Fira Code", monospace
width: 100%
box-sizing: border-box
> thead
> tr > td > span
align-items: center
justify-items: center
display: flex
cursor: pointer
user-select: none

View File

@@ -0,0 +1,35 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> div.instance
> div.preference-table
.select-client
display: flex
align-items: center
img.avatar, svg.avatar
max-height: 1.375rem
border-radius: 50%
margin-right: .5rem
> div.ace_editor
z-index: 0
height: 15rem !important
width: calc(100% - 1rem) !important
font-size: 12px
font-family: "Fira Code", monospace
margin: .75rem .5rem 1.5rem

View File

@@ -0,0 +1,89 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
div.log
height: 100%
width: 100%
overflow: auto
div.log > div.lines
text-align: left
font-size: 12px
max-height: 100%
min-width: 100%
font-family: "Fira Code", monospace
display: table
div.log > div.lines > div.row
display: table-row
white-space: pre
&.trace
opacity: .75
&.debug, &.trace
background-color: $background
&:nth-child(odd)
background-color: $background-dark
&.info
background-color: #AAFAFA
&:nth-child(odd)
background-color: #66FAFA
&.warning, &.warn
background-color: #FABB77
&:nth-child(odd)
background-color: #FAAA55
&.error
background-color: #FAAAAA
&:nth-child(odd)
background-color: #FA9999
&.fatal
background-color: #CC44CC
&:nth-child(odd)
background-color: #AA44AA
&.unfocused
opacity: .25
> span
padding: .125rem .25rem
display: table-cell
&:first-child
padding-left: 0
&:last-child
padding-right: 0
a
color: inherit
text-decoration: none
&:hover
text-decoration: underline
> span.text
> div.content > *
background-color: inherit !important
margin: 0 !important

View File

@@ -0,0 +1,62 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
.maubot-wrapper:not(.authenticated)
background-color: $primary
text-align: center
.login
width: 25rem
height: 23rem
display: inline-block
box-sizing: border-box
background-color: white
border-radius: .25rem
margin-top: 3rem
@media screen and (max-width: 27rem)
margin: 3rem 1rem 0
width: calc(100% - 2rem)
h1
color: $primary
margin: 3rem 0
input, button
margin: .5rem 2.5rem
height: 3rem
width: calc(100% - 5rem)
box-sizing: border-box
input
+input
button
+button($width: calc(100% - 5rem), $height: 3rem, $padding: 0)
+main-color-button
.spinner
+white-spinner
+thick-spinner
width: 2rem
&.errored
height: 26.5rem
.error
+notification($error)
margin: .5rem 2.5rem

View File

@@ -0,0 +1,40 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
=instancelist()
margin: 1rem 0
display: flex
flex-wrap: wrap
> h3
margin: .5rem
width: 100%
> a.instance
display: block
width: calc(50% - 1rem)
padding: .375rem .5rem
margin: .5rem
background-color: white
border-radius: .25rem
color: $text-color
text-decoration: none
box-sizing: border-box
border: 1px solid $primary
&:hover
background-color: $primary

View File

@@ -0,0 +1,43 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
=upload-box()
position: relative
overflow: hidden
display: flex
align-items: center
justify-content: center
> svg.upload
position: absolute
display: block
padding: 1rem
user-select: none
> input.file-selector
position: absolute
user-select: none
opacity: 0
> div.spinner
+thick-spinner
&:not(.uploading)
> input.file-selector
cursor: pointer

View File

@@ -0,0 +1,71 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
div.modal-wrapper-wrapper
z-index: 9001
position: fixed
top: 0
bottom: 0
left: 0
right: 0
background-color: rgba(0, 0, 0, 0.5)
--modal-margin: 2.5rem
--button-height: 0rem
@media screen and (max-width: 45rem)
--modal-margin: 1rem
--button-height: 2.5rem
@media screen and (max-width: 35rem)
--modal-margin: 0rem
--button-height: 3rem
button.close
+button
display: none
width: 100%
height: var(--button-height)
border-radius: .25rem .25rem 0 0
@media screen and (max-width: 45rem)
display: block
@media screen and (max-width: 35rem)
border-radius: 0
div.modal-wrapper
width: calc(100% - 2 * var(--modal-margin))
height: calc(100% - 2 * var(--modal-margin) - var(--button-height))
margin: var(--modal-margin)
border-radius: .25rem
@media screen and (max-width: 35rem)
border-radius: 0
div.modal
padding: 1rem
height: 100%
width: 100%
background-color: $background
box-sizing: border-box
border-radius: .25rem
@media screen and (max-width: 45rem)
border-radius: 0 0 .25rem .25rem
@media screen and (max-width: 35rem)
border-radius: 0
padding: .5rem

View File

@@ -0,0 +1,53 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> .plugin
> .upload-box
+upload-box
width: calc(100% - 1rem)
height: 10rem
margin: .5rem
border-radius: .5rem
box-sizing: border-box
border: .25rem dotted $primary
> svg.upload
width: 8rem
height: 8rem
opacity: .5
> input.file-selector
width: 100%
height: 100%
&:not(.uploading):hover, &:not(.uploading).drag
border: .25rem solid $primary
background-color: $primary-light
> svg.upload
opacity: 1
&.uploading
> svg.upload
visibility: hidden
> input.file-selector
cursor: default
> div.instances
+instancelist

View File

@@ -0,0 +1,79 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> nav.sidebar
grid-area: sidebar
background-color: white
padding: .5rem
overflow-y: auto
div.buttons
margin-bottom: 1.5rem
button
+button
background-color: white
width: 100%
div.list
&:not(:last-of-type)
margin-bottom: 1.5rem
div.title
h2
display: inline-block
margin: 0 0 .25rem 0
font-size: 1.25rem
a
display: inline-block
float: right
a.entry
display: block
color: $text-color
text-decoration: none
padding: .25rem
border-radius: .25rem
height: 2rem
box-sizing: border-box
white-space: nowrap
overflow-x: hidden
&:not(:hover) > svg.chevron
display: none
> svg.chevron
float: right
&:hover
background-color: $primary-light
&.active
background-color: $primary
color: white
&.client
img.avatar, svg.avatar
max-height: 1.5rem
border-radius: 100%
vertical-align: middle
span.displayname, span.id
margin-left: .25rem
vertical-align: middle

View File

@@ -0,0 +1,79 @@
// maubot - A plugin-based Matrix bot system.
// Copyright (C) 2022 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/>.
> .topbar
background-color: $primary
display: flex
justify-items: center
align-items: center
padding: 0 .75rem
@media screen and (min-width: calc(35rem + 1px))
display: none
// Hamburger menu based on "Pure CSS Hamburger fold-out menu" codepen by Erik Terwan (MIT license)
// https://codepen.io/erikterwan/pen/EVzeRP
> .topbar
user-select: none
> .topbar > .hamburger
display: block
user-select: none
cursor: pointer
> span
display: block
width: 29px
height: 4px
margin-bottom: 5px
position: relative
background: white
border-radius: 3px
user-select: none
z-index: 1
transform-origin: 4px 0
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0), background 0.5s cubic-bezier(0.77, 0.2, 0.05, 1.0), opacity 0.55s ease
&:nth-of-type(1)
transform-origin: 0 0
&:nth-of-type(3)
transform-origin: 0 100%
transform: translateY(2px)
transition: transform 0.4s cubic-bezier(0.77, 0.2, 0.05, 1.0)
&.active
transform: translateX(1px) translateY(4px)
&.active > span
opacity: 1
&:nth-of-type(1)
transform: rotate(45deg) translate(-2px, -1px)
&:nth-of-type(2)
opacity: 0
transform: rotate(0deg) scale(0.2, 0.2)
&:nth-of-type(3)
transform: rotate(-45deg) translate(0, -1px)

File diff suppressed because it is too large Load Diff