mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 18:44:16 +00:00
commit
0e6889e676
@ -1,16 +0,0 @@
|
|||||||
# http://editorconfig.org
|
|
||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[Makefile]
|
|
||||||
indent_style = tab
|
|
49
.gitignore
vendored
Executable file → Normal file
49
.gitignore
vendored
Executable file → Normal file
@ -1,26 +1,29 @@
|
|||||||
# dependencies
|
# Secrets
|
||||||
/node_modules
|
/**/**/.env
|
||||||
/npm-debug.log*
|
/**/**/origin.server
|
||||||
/yarn-error.log
|
/**/**/server.manifest
|
||||||
/yarn.lock
|
/**/**/server.registry
|
||||||
|
|
||||||
# production
|
# Trash
|
||||||
/build
|
/**/**/.crash.log
|
||||||
/dist
|
/**/**/.tmp
|
||||||
/out
|
/**/**/.cache
|
||||||
|
/**/**/out
|
||||||
|
/**/**/.out
|
||||||
|
/**/**/dist
|
||||||
|
/**/**/node_modules
|
||||||
|
/**/**/corenode_modules
|
||||||
|
/**/**/.DS_Store
|
||||||
|
/**/**/package-lock.json
|
||||||
|
/**/**/yarn.lock
|
||||||
|
/**/**/.evite
|
||||||
|
/**/**/d_data
|
||||||
|
|
||||||
# umi
|
# Logs
|
||||||
/packages/**/src/.umi
|
/**/**/npm-debug.log*
|
||||||
/packages/**/src/.umi-production
|
/**/**/yarn-error.log
|
||||||
/packages/**/src/.umi-test
|
/**/**/dumps.log
|
||||||
/packages/**/.env.local
|
/**/**/corenode.log
|
||||||
|
|
||||||
/packages/*/src/.umi
|
# Temporal configurations
|
||||||
/packages/*/src/.umi-production
|
/**/**/.aliaser
|
||||||
/packages/*/src/.umi-test
|
|
||||||
/packages/*/.env.local
|
|
||||||
|
|
||||||
/.env.local
|
|
||||||
|
|
||||||
/packages/*/node_modules
|
|
||||||
/packages/**/node_modules
|
|
11
.nodecore
11
.nodecore
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.12.8",
|
|
||||||
"devRuntime": {
|
|
||||||
"headPackage": "ragestudio"
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"src": "/src",
|
|
||||||
"UUID": "C8mVSr-4nmPp2-pr5Vrz-CU4kg4",
|
|
||||||
"stage": "alpha"
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
**/*.md
|
|
||||||
**/*.svg
|
|
||||||
**/*.ejs
|
|
||||||
**/*.html
|
|
||||||
package.json
|
|
||||||
.umi
|
|
||||||
.umi-production
|
|
||||||
.umi-test
|
|
11
.prettierrc
11
.prettierrc
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"printWidth": 80,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ".prettierrc",
|
|
||||||
"options": { "parser": "json" }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
# Callback Codes
|
|
||||||
|
|
||||||
## 000 - 100 > Runtime
|
|
||||||
|
|
||||||
| code | type |
|
|
||||||
|--|--|
|
|
||||||
| | |
|
|
||||||
|
|
||||||
|
|
||||||
## 100 - 200 > Operations results
|
|
||||||
|
|
||||||
| code | type | description
|
|
||||||
|--|--|--|
|
|
||||||
| 100 | successful operation | |
|
|
||||||
| 110 | failed operation | unhandled |
|
|
||||||
| 115 | invalid operation | |
|
|
||||||
> API/WS Requests callbacks codes
|
|
||||||
|
|
||||||
| | | |
|
|
||||||
|--|--|--|
|
|
||||||
| 130 | needs auth | |
|
|
||||||
| 131 | no user send | |
|
|
||||||
| 132 | no id_user send | |
|
|
||||||
| 133 | no password send | |
|
|
||||||
| 134 | no token send | |
|
|
||||||
| 135 | no server_key send | |
|
|
||||||
| 136 | no payload send | |
|
|
||||||
> API/WS Invalid requests
|
|
||||||
|
|
||||||
| | | |
|
|
||||||
|--|--|--|
|
|
||||||
| 140 | invalid auth |
|
|
||||||
| 141 | invalid/notfound user |
|
|
||||||
| 142 | invalid/notfound id_user |
|
|
||||||
| 143 | invalid password |
|
|
||||||
| 144 | invalid/notfound token |
|
|
||||||
| 145 | invalid server_key | fails when the sended server_key is not valid |
|
|
||||||
| 146 | invalid payload | bad typeof / missing parameter / bad parameter |
|
|
||||||
|
|
||||||
## 200 - 300 > Permissions
|
|
4
dumps.log
Normal file
4
dumps.log
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
> 2021-11-16T16:47:12.984Z (anonymous)[info] : sync versions on package.json [/home/srgooglo/repos/comty/packages/comty] > /home/srgooglo/repos/comty/packages/comty/package.json
|
||||||
|
> 2021-11-16T16:47:12.985Z (anonymous)[info] : sync versions on package.json [/home/srgooglo/repos/comty/packages/server] > /home/srgooglo/repos/comty/packages/server/package.json
|
||||||
|
> 2021-11-16T16:47:12.985Z (anonymous)[info] : sync versions on package.json [/home/srgooglo/repos/comty/packages/wrapper] > /home/srgooglo/repos/comty/packages/wrapper/package.json
|
||||||
|
> 2021-11-16T16:47:12.986Z (anonymous)[info] : sync versions on package.json [/home/srgooglo/repos/comty] > /home/srgooglo/repos/comty/package.json
|
4170
package-lock.json
generated
4170
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
25
package.json
25
package.json
@ -6,27 +6,8 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages"
|
"packages"
|
||||||
],
|
],
|
||||||
"lint-staged": {
|
|
||||||
"*.{js,jsx,less,md,json}": [
|
|
||||||
"prettier --write"
|
|
||||||
],
|
|
||||||
"*.ts?(x)": [
|
|
||||||
"prettier --parser=typescript --write"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"postinstall": "cd ./packages/comty && npm i",
|
|
||||||
"start": "cd ./packages/comty && npm start",
|
|
||||||
"update:deps": "yarn upgrade-interactive --latest"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
|
|
||||||
"@types/node": "^14.14.20",
|
|
||||||
"concurrently": "^5.3.0",
|
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"jsdoc": "^3.6.5"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ragestudio/nodecorejs": "^0.15.1"
|
"corenode": "^0.28.26"
|
||||||
}
|
},
|
||||||
|
"version": "0.13.0"
|
||||||
}
|
}
|
||||||
|
69
packages/app/.config.js
Normal file
69
packages/app/.config.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
const path = require("path")
|
||||||
|
const { builtinModules } = require("module")
|
||||||
|
const { node } = require("../desktop/.electron-vendors.cache.json")
|
||||||
|
|
||||||
|
const aliases = {
|
||||||
|
"~/": `${path.resolve(__dirname, "src")}/`,
|
||||||
|
"__": __dirname,
|
||||||
|
"@src": path.resolve(__dirname, "src"),
|
||||||
|
schemas: path.resolve(__dirname, "constants"),
|
||||||
|
config: path.join(__dirname, "config"),
|
||||||
|
extensions: path.resolve(__dirname, "src/extensions"),
|
||||||
|
pages: path.join(__dirname, "src/pages"),
|
||||||
|
theme: path.join(__dirname, "src/theme"),
|
||||||
|
components: path.join(__dirname, "src/components"),
|
||||||
|
models: path.join(__dirname, "src/models"),
|
||||||
|
utils: path.join(__dirname, "src/utils"),
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = (config = {}) => {
|
||||||
|
if (!config.resolve) {
|
||||||
|
config.resolve = {}
|
||||||
|
}
|
||||||
|
if (!config.server) {
|
||||||
|
config.server = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.resolve.alias = aliases
|
||||||
|
config.server.port = process.env.listenPort ?? 8000
|
||||||
|
config.server.host = "0.0.0.0"
|
||||||
|
config.server.fs = {
|
||||||
|
allow: [".."]
|
||||||
|
}
|
||||||
|
|
||||||
|
config.envDir = path.join(__dirname, "environments")
|
||||||
|
|
||||||
|
config.css = {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
javascriptEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.build = {
|
||||||
|
sourcemap: "inline",
|
||||||
|
target: `node${node}`,
|
||||||
|
outDir: "dist",
|
||||||
|
assetsDir: ".",
|
||||||
|
minify: process.env.MODE !== "development",
|
||||||
|
lib: {
|
||||||
|
entry: "src/index.ts",
|
||||||
|
formats: ["cjs"],
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
"electron",
|
||||||
|
"electron-devtools-installer",
|
||||||
|
...builtinModules.flatMap(p => [p, `node:${p}`]),
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: "[name].cjs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emptyOutDir: true,
|
||||||
|
brotliSize: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
8
packages/app/.gitignore
vendored
Normal file
8
packages/app/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.vscode
|
||||||
|
yarn-error.log
|
||||||
|
ios/**/**
|
7
packages/app/capacitor.config.json
Normal file
7
packages/app/capacitor.config.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"appId": "com.ragestudio.comty",
|
||||||
|
"appName": "Comty",
|
||||||
|
"bundledWebRuntime": true,
|
||||||
|
"overrideUserAgent": "capacitor",
|
||||||
|
"webDir": "dist"
|
||||||
|
}
|
46
packages/app/config/index.js
Normal file
46
packages/app/config/index.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import packagejson from "../package.json"
|
||||||
|
import defaultTheme from "../constants/defaultTheme.json"
|
||||||
|
import defaultSoundPack from "../constants/defaultSoundPack.json"
|
||||||
|
import defaultRemotesOrigins from "../constants/defaultRemotesOrigins.json"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
package: packagejson,
|
||||||
|
defaultTheme: defaultTheme,
|
||||||
|
defaultSoundPack: defaultSoundPack,
|
||||||
|
author: "RageStudio© 2022",
|
||||||
|
logo: {
|
||||||
|
alt: "/logo_alt.svg",
|
||||||
|
full: "/logo_full.svg",
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
address: defaultRemotesOrigins.http_api,//process.env.NODE_ENV !== "production" ? `http://${window.location.hostname}:3000` : defaultRemotesOrigins.http_api,
|
||||||
|
},
|
||||||
|
ws: {
|
||||||
|
address: defaultRemotesOrigins.ws_api, //process.env.NODE_ENV !== "production" ? `ws://${window.location.hostname}:3001` : defaultRemotesOrigins.ws_api,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
title: packagejson.name,
|
||||||
|
siteName: "Comty",
|
||||||
|
mainPath: "/main",
|
||||||
|
storage: {
|
||||||
|
basics: "user",
|
||||||
|
token: "token",
|
||||||
|
session_frame: "session",
|
||||||
|
signkey: "certified",
|
||||||
|
settings: "app_settings"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
languages: [
|
||||||
|
{
|
||||||
|
locale: "en",
|
||||||
|
name: "English"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: "es",
|
||||||
|
name: "Español"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
defaultLocale: "es",
|
||||||
|
}
|
||||||
|
}
|
4
packages/app/constants/defaultRemotesOrigins.json
Normal file
4
packages/app/constants/defaultRemotesOrigins.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"http_api": "https://indev_api.comty.pw",
|
||||||
|
"ws_api": "wss://indev_ws.comty.pw"
|
||||||
|
}
|
18
packages/app/constants/defaultSettings.json
Normal file
18
packages/app/constants/defaultSettings.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"forceMobileMode": false,
|
||||||
|
"notifications_sound": true,
|
||||||
|
"notifications_vibrate": true,
|
||||||
|
"notifications_sound_volume": 50,
|
||||||
|
"selection_longPress_timeout": 600,
|
||||||
|
"autoCollapseDelay": 500,
|
||||||
|
"autoCollapseDelayEnabled": true,
|
||||||
|
"haptic_feedback": false,
|
||||||
|
"collapseOnLooseFocus": true,
|
||||||
|
"language": "en",
|
||||||
|
"sidebarKeys": [
|
||||||
|
"main",
|
||||||
|
"explore",
|
||||||
|
"saved",
|
||||||
|
"marketplace"
|
||||||
|
]
|
||||||
|
}
|
5
packages/app/constants/defaultSoundPack.json
Normal file
5
packages/app/constants/defaultSoundPack.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"crash": "/sounds/crash.wav",
|
||||||
|
"error": "/sounds/error.wav",
|
||||||
|
"notification": "/sounds/notification.wav"
|
||||||
|
}
|
37
packages/app/constants/defaultTheme.json
Normal file
37
packages/app/constants/defaultTheme.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"staticVars": {
|
||||||
|
"primaryColor": "#ff6064",
|
||||||
|
"fontFamily": "'Varela Round', sans-serif"
|
||||||
|
},
|
||||||
|
"defaultVariant": "light",
|
||||||
|
"variants": {
|
||||||
|
"light": {
|
||||||
|
"appColor": "#ff6064",
|
||||||
|
"background-color-primary": "#ffffff",
|
||||||
|
"background-color-primary2": "#f0f0f0",
|
||||||
|
"shadow-color": "#4b4b4b7c",
|
||||||
|
"background-color-accent": "#f0f2f5",
|
||||||
|
"background-color-contrast": "#4b4b4b",
|
||||||
|
"border-color": "#4b4b4b2a",
|
||||||
|
"sidebar-background-color": "var(--background-color-accent)",
|
||||||
|
"sidedrawer-background-color": "var(--background-color-accent)"
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"appColor": "#ff6064",
|
||||||
|
"text-color": "#d2d2d2",
|
||||||
|
"svg-color": "var(--text-color)",
|
||||||
|
"shadow-color": "#3535357c",
|
||||||
|
"background-color-primary": "#262626",
|
||||||
|
"background-color-primary2": "#2c2c2c",
|
||||||
|
"background-color-accent": "#353535",
|
||||||
|
"background_disabled": "#0A0A0A",
|
||||||
|
"background-color-contrast": "#ffffff",
|
||||||
|
"border-color": "#4b4b4b2a",
|
||||||
|
"header-text-color": "#d2d2d2",
|
||||||
|
"button-background-color": "var(--primaryColor)",
|
||||||
|
"button-text-color": "var(--background-color-contrast)",
|
||||||
|
"sidebar-background-color": "var(--background-color-accent)",
|
||||||
|
"sidedrawer-background-color": "var(--background-color-accent)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
14
packages/app/constants/pathDecorators.json
Normal file
14
packages/app/constants/pathDecorators.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"main": {
|
||||||
|
"icon": "Home",
|
||||||
|
"title": "Main"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"icon": "User",
|
||||||
|
"title": "Account"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"icon": "Users",
|
||||||
|
"title": "Users"
|
||||||
|
}
|
||||||
|
}
|
22
packages/app/constants/routes.json
Normal file
22
packages/app/constants/routes.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"title": "Dashboard",
|
||||||
|
"icon": "Home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "explore",
|
||||||
|
"title": "Explore",
|
||||||
|
"icon": "Compass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "saved",
|
||||||
|
"title": "Saved",
|
||||||
|
"icon": "Archive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marketplace",
|
||||||
|
"title": "Marketplace",
|
||||||
|
"icon": "Package"
|
||||||
|
}
|
||||||
|
]
|
103
packages/app/constants/settings/account.jsx
Normal file
103
packages/app/constants/settings/account.jsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { User } from "models"
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
"id": "username",
|
||||||
|
"group": "account.basicInfo",
|
||||||
|
"type": "Button",
|
||||||
|
"icon": "AtSign",
|
||||||
|
"title": "Username",
|
||||||
|
"description": "Your username is the name you use to log in to your account.",
|
||||||
|
"props": {
|
||||||
|
"disabled": true,
|
||||||
|
"children": "Change username",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fullName",
|
||||||
|
"group": "account.basicInfo",
|
||||||
|
"type": "Input",
|
||||||
|
"icon": "Edit3",
|
||||||
|
"title": "Name",
|
||||||
|
"description": "Change your public name",
|
||||||
|
"props": {
|
||||||
|
"placeholder": "Enter your name. e.g. John Doe",
|
||||||
|
},
|
||||||
|
"defaultValue": async () => {
|
||||||
|
const userData = await User.data()
|
||||||
|
return userData.fullName
|
||||||
|
},
|
||||||
|
"onUpdate": async (value) => {
|
||||||
|
const selfId = await User.selfUserId()
|
||||||
|
|
||||||
|
const result = window.app.request.post.updateUser({
|
||||||
|
_id: selfId,
|
||||||
|
update: {
|
||||||
|
fullName: value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"extraActions": [
|
||||||
|
{
|
||||||
|
"id": "unset",
|
||||||
|
"icon": "Delete",
|
||||||
|
"title": "Unset",
|
||||||
|
"onClick": async () => {
|
||||||
|
window.app.request.post.unsetPublicName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"debounced": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email",
|
||||||
|
"group": "account.basicInfo",
|
||||||
|
"type": "Input",
|
||||||
|
"icon": "Mail",
|
||||||
|
"title": "Email",
|
||||||
|
"description": "Change your email address",
|
||||||
|
"props": {
|
||||||
|
"placeholder": "Enter your email address",
|
||||||
|
},
|
||||||
|
"defaultValue": async () => {
|
||||||
|
const userData = await User.data()
|
||||||
|
return userData.email
|
||||||
|
},
|
||||||
|
"onUpdate": async (value) => {
|
||||||
|
const selfId = await User.selfUserId()
|
||||||
|
|
||||||
|
const result = window.app.request.post.updateUser({
|
||||||
|
_id: selfId,
|
||||||
|
update: {
|
||||||
|
email: value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"debounced": true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "Avatar",
|
||||||
|
"group": "account.basicInfo",
|
||||||
|
"type": "ImageUpload",
|
||||||
|
"icon": "Image",
|
||||||
|
"title": "Avatar",
|
||||||
|
"description": "Change your avatar",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "primaryBadge",
|
||||||
|
"group": "account.basicInfo",
|
||||||
|
"type": "Select",
|
||||||
|
"icon": "Tag",
|
||||||
|
"title": "Primary badge",
|
||||||
|
"description": "Change your primary badge",
|
||||||
|
},
|
||||||
|
]
|
185
packages/app/constants/settings/app.jsx
Normal file
185
packages/app/constants/settings/app.jsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React from "react"
|
||||||
|
import config from "config"
|
||||||
|
import { Select } from "antd"
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
"id": "language",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "general",
|
||||||
|
"type": "Select",
|
||||||
|
"icon": "MdTranslate",
|
||||||
|
"title": "Language",
|
||||||
|
"description": "Choose a language for the application",
|
||||||
|
"props": {
|
||||||
|
children: config.i18n.languages.map((language) => {
|
||||||
|
return <Select.Option value={language.locale}>{language.name}</Select.Option>
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"emitEvent": "changeLanguage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "forceMobileMode",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "general",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "MdSmartphone",
|
||||||
|
"title": "Force Mobile Mode",
|
||||||
|
"description": "Force the application to run in mobile mode.",
|
||||||
|
"emitEvent": "forceMobileMode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "haptic_feedback",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "general",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "MdVibration",
|
||||||
|
"title": "Haptic Feedback",
|
||||||
|
"description": "Enable haptic feedback on touch events.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "selection_longPress_timeout",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "general",
|
||||||
|
"type": "Slider",
|
||||||
|
"icon": "MdTimer",
|
||||||
|
"title": "Selection press delay",
|
||||||
|
"description": "Set the delay before the selection trigger is activated.",
|
||||||
|
"props": {
|
||||||
|
min: 300,
|
||||||
|
max: 2000,
|
||||||
|
step: 100,
|
||||||
|
marks: {
|
||||||
|
300: "0.3s",
|
||||||
|
600: "0.6s",
|
||||||
|
1000: "1s",
|
||||||
|
1500: "1.5s",
|
||||||
|
2000: "2s",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notifications_sound",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "notifications",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "MdVolumeUp",
|
||||||
|
"title": "Notifications Sound",
|
||||||
|
"description": "Play a sound when a notification is received.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notifications_vibrate",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "notifications",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "MdVibration",
|
||||||
|
"title": "Vibration",
|
||||||
|
"description": "Vibrate the device when a notification is received.",
|
||||||
|
"emitEvent": "changeNotificationsVibrate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notifications_sound_volume",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "notifications",
|
||||||
|
"type": "Slider",
|
||||||
|
"icon": "MdVolumeUp",
|
||||||
|
"title": "Sound Volume",
|
||||||
|
"description": "Set the volume of the sound when a notification is received.",
|
||||||
|
"props": {
|
||||||
|
tipFormatter: (value) => {
|
||||||
|
return `${value}%`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"emitEvent": "changeNotificationsSoundVolume"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "edit_sidebar",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "sidebar",
|
||||||
|
"type": "Button",
|
||||||
|
"icon": "Edit",
|
||||||
|
"title": "Edit Sidebar",
|
||||||
|
"emitEvent": "edit_sidebar",
|
||||||
|
"noStorage": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collapseOnLooseFocus",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "sidebar",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "Columns",
|
||||||
|
"title": "Auto Collapse",
|
||||||
|
"description": "Collapse the sidebar when loose focus",
|
||||||
|
"emitEvent": "settingChanged.sidebar_collapse",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "autoCollapseDelay",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "sidebar",
|
||||||
|
"type": "Slider",
|
||||||
|
"icon": "Wh",
|
||||||
|
"dependsOn": {
|
||||||
|
"collapseOnLooseFocus": true
|
||||||
|
},
|
||||||
|
"title": "Auto Collapse timeout",
|
||||||
|
"description": "Set the delay before the sidebar is collapsed",
|
||||||
|
"props": {
|
||||||
|
min: 0,
|
||||||
|
max: 2000,
|
||||||
|
step: 100,
|
||||||
|
marks: {
|
||||||
|
0: "No delay",
|
||||||
|
600: "0.6s",
|
||||||
|
1000: "1s",
|
||||||
|
1500: "1.5s",
|
||||||
|
2000: "2s",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reduceAnimations",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "aspect",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "MdOutlineAnimation",
|
||||||
|
"title": "Reduce animation",
|
||||||
|
"experimental": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "darkMode",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "aspect",
|
||||||
|
"type": "Switch",
|
||||||
|
"icon": "Moon",
|
||||||
|
"title": "Dark mode",
|
||||||
|
"emitEvent": "darkMode",
|
||||||
|
"experimental": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "primaryColor",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "aspect",
|
||||||
|
"type": "SliderColorPicker",
|
||||||
|
"title": "Primary color",
|
||||||
|
"description": "Change primary color of the application.",
|
||||||
|
"emitEvent": "modifyTheme",
|
||||||
|
"reloadValueOnUpdateEvent": "resetTheme",
|
||||||
|
"emissionValueUpdate": (value) => {
|
||||||
|
return {
|
||||||
|
primaryColor: value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "resetTheme",
|
||||||
|
"storaged": true,
|
||||||
|
"group": "aspect",
|
||||||
|
"type": "Button",
|
||||||
|
"title": "Reset theme",
|
||||||
|
"props": {
|
||||||
|
"children": "Default Theme"
|
||||||
|
},
|
||||||
|
"emitEvent": "resetTheme",
|
||||||
|
"noUpdate": true,
|
||||||
|
}
|
||||||
|
]
|
22
packages/app/constants/settingsGroupsDecorator.json
Normal file
22
packages/app/constants/settingsGroupsDecorator.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"general": {
|
||||||
|
"title": "General",
|
||||||
|
"icon": "Settings"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"icon": "Bell"
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Sidebar",
|
||||||
|
"icon": "Layout"
|
||||||
|
},
|
||||||
|
"aspect": {
|
||||||
|
"title": "Aspect",
|
||||||
|
"icon": "Eye"
|
||||||
|
},
|
||||||
|
"account.basicInfo": {
|
||||||
|
"title": "Basic Information",
|
||||||
|
"icon": "Info"
|
||||||
|
}
|
||||||
|
}
|
12
packages/app/index.html
Normal file
12
packages/app/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/vite.entry.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
101
packages/app/package.json
Normal file
101
packages/app/package.json
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"name": "comty",
|
||||||
|
"version": "0.15.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:ssr": "vite-ssr",
|
||||||
|
"sync": "capacitor sync ios && capacitor sync android",
|
||||||
|
"build": "cross-env NODE_ENV=production vite build && yarn sync",
|
||||||
|
"build:dist": "cross-env NODE_ENV=production vite build",
|
||||||
|
"build:preview": "cross-env NODE_ENV=preview vite build && yarn sync",
|
||||||
|
"build:ssr": "cross-env NODE_ENV=production vite-ssr build && ./scripts/move-dist.sh",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"capacitor": "capacitor"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.6"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "4.7.0",
|
||||||
|
"@capacitor/android": "^3.4.0",
|
||||||
|
"@capacitor/haptics": "^1.1.4",
|
||||||
|
"@capacitor/push-notifications": "^1.0.9",
|
||||||
|
"@capacitor/status-bar": "1.0.7",
|
||||||
|
"@capacitor/storage": "1.2.4",
|
||||||
|
"@corenode/utils": "0.28.26",
|
||||||
|
"@emotion/css": "11.0.0",
|
||||||
|
"@foxify/events": "2.0.1",
|
||||||
|
"@loadable/component": "5.15.2",
|
||||||
|
"antd": "^4.19.1",
|
||||||
|
"antd-mobile": "^5.0.0-rc.17",
|
||||||
|
"chart.js": "3.7.0",
|
||||||
|
"classnames": "2.3.1",
|
||||||
|
"evite": "0.9.5",
|
||||||
|
"faye": "1.4.0",
|
||||||
|
"feather-reactjs": "2.0.13",
|
||||||
|
"fuse.js": "6.5.3",
|
||||||
|
"global": "4.4.0",
|
||||||
|
"history": "5.2.0",
|
||||||
|
"hls.js": "^1.1.5",
|
||||||
|
"howler": "2.2.3",
|
||||||
|
"i18next": "21.6.6",
|
||||||
|
"js-cookie": "3.0.1",
|
||||||
|
"jwt-decode": "3.1.2",
|
||||||
|
"less": "4.1.2",
|
||||||
|
"linebridge": "0.10.13",
|
||||||
|
"moment": "2.29.1",
|
||||||
|
"mpegts.js": "^1.6.10",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
|
"plyr": "^3.6.12",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"qrcode": "1.5.0",
|
||||||
|
"rc-animate": "^3.1.1",
|
||||||
|
"rc-util": "^5.19.3",
|
||||||
|
"rc-virtual-list": "^3.4.4",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-beautiful-dnd": "13.1.0",
|
||||||
|
"react-chartjs-2": "4.0.1",
|
||||||
|
"react-color": "2.19.3",
|
||||||
|
"react-contexify": "5.0.0",
|
||||||
|
"react-dom": "17.0.2",
|
||||||
|
"react-draggable": "4.4.4",
|
||||||
|
"react-helmet": "6.1.0",
|
||||||
|
"react-i18next": "11.15.3",
|
||||||
|
"react-icons": "4.3.1",
|
||||||
|
"react-intersection-observer": "8.33.1",
|
||||||
|
"react-json-view": "1.21.3",
|
||||||
|
"react-lazy-load-image-component": "^1.5.1",
|
||||||
|
"react-motion": "0.5.2",
|
||||||
|
"react-reveal": "1.2.2",
|
||||||
|
"react-rnd": "10.3.5",
|
||||||
|
"react-router": "6.2.1",
|
||||||
|
"react-router-config": "^5.1.1",
|
||||||
|
"react-router-dom": "6.2.1",
|
||||||
|
"react-virtualized": "^9.22.3",
|
||||||
|
"store": "^2.0.12",
|
||||||
|
"styled-components": "^5.3.3",
|
||||||
|
"vite-ssr": "0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@capacitor/cli": "3.2.2",
|
||||||
|
"@capacitor/core": "3.2.2",
|
||||||
|
"@capacitor/ios": "3.0.2",
|
||||||
|
"@capacitor/project": "1.0.28",
|
||||||
|
"@types/jest": "^26.0.24",
|
||||||
|
"@types/node": "^16.4.10",
|
||||||
|
"@types/react": "^17.0.15",
|
||||||
|
"@types/react-dom": "^17.0.9",
|
||||||
|
"@types/react-router-config": "^5.0.3",
|
||||||
|
"@types/react-router-dom": "^5.1.8",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||||
|
"@vitejs/plugin-react-refresh": "^1.3.6",
|
||||||
|
"corenode": "0.28.26",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"typescript": "^4.3.5",
|
||||||
|
"vite": "2.7.13",
|
||||||
|
"vite-plugin-next-react-router": "^0.6.2",
|
||||||
|
"vite-plugin-pages": "0.12.x"
|
||||||
|
}
|
||||||
|
}
|
8
packages/app/public/broken-image.svg
Normal file
8
packages/app/public/broken-image.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg
|
||||||
|
style="width: 1em; height: 1em;vertical-align: middle;fill: var(--background-color-contrast);overflow: hidden;"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
><path
|
||||||
|
d="M896 213.333333v281.6l-128-128-170.666667 170.666667-170.666666-170.666667-170.666667 170.666667-128-128V213.333333c0-46.933333 38.4-85.333333 85.333333-85.333333h597.333334c46.933333 0 85.333333 38.4 85.333333 85.333333z m-128 273.066667l128 128V810.666667c0 46.933333-38.4 85.333333-85.333333 85.333333H213.333333c-46.933333 0-85.333333-38.4-85.333333-85.333333V529.066667l128 128 170.666667-170.666667 170.666666 170.666667 170.666667-170.666667z"
|
||||||
|
/></svg>
|
After Width: | Height: | Size: 667 B |
BIN
packages/app/public/favicon.ico
Normal file
BIN
packages/app/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 MiB |
22
packages/app/public/logo_alt.svg
Normal file
22
packages/app/public/logo_alt.svg
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<svg id="a5b341d4-efcc-46ea-a271-f2bea59c6abd" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 73.95833 75.98684">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="ff5fefc3-adb2-4abb-b227-248fe5798176" transform="translate(-59.54167 -41.51316)">
|
||||||
|
<path d="M65.66466,94.80263h11.627V77.03947h7.12138A17.46634,17.46634,0,0,1,89.91264,67.661a16.65479,16.65479,0,0,1,5.129-3.01224v-5.3724h17.75V44.65313a38.859,38.859,0,0,0-13.03991-2.15171H66.44444A3.9459,3.9459,0,0,0,62.5,46.44878V80a37.22971,37.22971,0,0,0,3.16466,14.80263Z" style="fill:none"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="ff89d78c-8832-4326-ac52-0a37a191ec6c" data-name="ISO">
|
||||||
|
<path
|
||||||
|
d="M117.10747,71.60975l15.661-8.2106a1.393,1.393,0,0,0,.56628-1.88339l-.0039-.0088a38.47206,38.47206,0,0,0-52.29612-13.895q-.61987.361-1.22711.74651A37.19166,37.19166,0,0,0,63.90489,89.91631a37.89348,37.89348,0,0,0,8.33249,15.0407,38.40645,38.40645,0,0,0,43.51431,9.43652,37.34524,37.34524,0,0,0,9.28377-5.794,1.3857,1.3857,0,0,0,.11794-1.94992l-.01852-.01957L113.45926,94.14684a1.39344,1.39344,0,0,0-1.85383-.16143,16.599,16.599,0,0,1-3.53612,2.23757,17.26606,17.26606,0,0,1-9.12587,1.33158c-8.05763-1.042-12.19512-7.694-12.49045-8.18028a17.37349,17.37349,0,0,1-2.348-10.45305,17.5888,17.5888,0,0,1,6.47768-11.82574,17.23688,17.23688,0,0,1,23.98672,2.9782c.27583.35319.5351.71813.78169,1.09286a1.3741,1.3741,0,0,0,1.75637.4432"
|
||||||
|
transform="translate(-59.54167 -41.51316)"
|
||||||
|
style="fill:#ff6064"
|
||||||
|
/>
|
||||||
|
<g style="clip-path:url(#ff5fefc3-adb2-4abb-b227-248fe5798176)">
|
||||||
|
<rect class="A1" width="17.75" height="17.76316" style="fill:#ff7c8c"/>
|
||||||
|
<rect class="A2" x="17.75" width="17.75" height="17.76316" style="fill:#ff4971"/>
|
||||||
|
<rect class="A3" x="35.5" width="17.75" height="17.76316" style="fill:#ff4457"/>
|
||||||
|
<rect class="B1" y="17.76316" width="17.75" height="17.76316" style="fill:#ff4971"/>
|
||||||
|
<rect class="B2" x="17.75" y="17.76316" width="17.75" height="17.76316" style="fill:#ff4457"/>
|
||||||
|
<rect class="C1" y="35.52632" width="17.75" height="17.76316" style="fill:#ff4457"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
48
packages/app/public/logo_full.svg
Normal file
48
packages/app/public/logo_full.svg
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<svg fill="#fff" id="b619fe7a-ff90-4eba-8fab-fc8da13c4d01" data-name="Capa 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 345 126.78261">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="f098983f-0e25-42ef-8a37-90969e9b2c72" x1="1.19966" y1="94.58815" x2="101.19961" y2="94.58815" gradientTransform="translate(0.23514 -0.02293)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0" stop-color="#ed6f56" />
|
||||||
|
<stop offset="1" stop-color="#ff6064" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="f19b29d4-1e8d-4178-855a-243e0912bbca">
|
||||||
|
<path d="M25.09943,65.36785h11.627V47.60469h7.12138a17.46641,17.46641,0,0,1,5.49959-9.37851,16.65459,16.65459,0,0,1,5.129-3.01224V29.84153h17.75V15.21835a38.85939,38.85939,0,0,0-13.03991-2.15172H25.87922A3.94591,3.94591,0,0,0,21.93477,17.014V50.56522a37.22971,37.22971,0,0,0,3.16466,14.80263Z" style="fill:none" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="a709f1c7-d8a7-4e50-b69c-38e7e1bf269b">
|
||||||
|
<path d="M107.4542,59.23431a13.32324,13.32324,0,1,1,13.37961,13.48405A13.32259,13.32259,0,0,1,107.4542,59.23431m-13.90215,0a27.27746,27.27746,0,1,0,27.28176-27.48574A27.25288,27.25288,0,0,0,93.552,59.23431" style="fill:#ff6064" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="b9696296-277b-4b6c-8f78-1a1a4f0442c4">
|
||||||
|
<path d="M198.98242,40.2153c-4.59926-6.47573-11.60267-8.46673-17.45618-8.46673-6.58021,0-11.17947,2.30458-14.31517,6.16712V32.27121H153.41829v53.304h13.79278V62.26561c0-11.493,3.44938-16.92349,11.39335-16.92349,7.62559,0,11.28413,5.11687,11.28413,16.92349V85.57523H203.686V62.26561c0-11.493,3.44953-16.92349,11.3935-16.92349,7.63061,0,11.289,5.11687,11.289,16.92349V85.57523h13.79263V58.29854c0-21.21907-12.64775-26.55-22.1598-26.55-7.93911,0-14.52433,3.03129-19.01894,8.46673" style="fill:none" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="bb055267-959c-42d8-b7c8-a796232b4837">
|
||||||
|
<polygon points="254.413 15.343 254.413 32.271 244.384 32.271 244.384 44.815 254.413 44.815 254.413 85.575 268.315 85.575 268.315 44.815 279.495 44.815 279.495 32.271 268.315 32.271 268.315 15.343 254.413 15.343" style="fill:none" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="f8754a63-63bf-4125-8805-22d5c19b3f68">
|
||||||
|
<polygon points="329.145 32.271 314.825 68.333 300.097 32.271 285.672 32.271 307.717 86.411 302.705 99.159 289.325 99.159 289.325 111.697 312.112 111.697 343.565 32.271 329.145 32.271" style="fill:none" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<g id="ac09f01a-134c-4431-8c16-92ddf77ea314" data-name="ISO">
|
||||||
|
<path d="M12.08564,71.33466,3.02945,75.57422A2.76967,2.76967,0,0,0,1.62592,79.0925a61.16433,61.16433,0,0,0,73.67317,36.66026,59.91038,59.91038,0,0,0,11.99952-4.92518,61.40873,61.40873,0,0,0,7.34947-4.65668,63.36948,63.36948,0,0,0,5.92619-4.95293,2.77329,2.77329,0,0,0,.20741-3.79571l-6.15333-7.33184c-.02765-.03467-.05728-.06836-.08691-.10105a2.73018,2.73018,0,0,0-3.86387-.19619,46.69194,46.69194,0,0,1-39.736,11.394A48.16334,48.16334,0,0,1,30.921,92.4979,47.91286,47.91286,0,0,1,15.79049,72.75149c-.01679-.04161-.03655-.08322-.05531-.12484a2.72948,2.72948,0,0,0-3.64954-1.292" style="fill:url(#f098983f-0e25-42ef-8a37-90969e9b2c72)" />
|
||||||
|
<path d="M76.54224,42.175l15.661-8.2106a1.393,1.393,0,0,0,.56628-1.88339l-.00389-.00881a38.47207,38.47207,0,0,0-52.29613-13.895q-.61987.361-1.22711.74651A37.19168,37.19168,0,0,0,23.33966,60.48153a37.89348,37.89348,0,0,0,8.33249,15.0407,38.40649,38.40649,0,0,0,43.51431,9.43652,37.3448,37.3448,0,0,0,9.28377-5.794,1.38568,1.38568,0,0,0,.11794-1.94991l-.01852-.01958L72.894,64.71206a1.39343,1.39343,0,0,0-1.85383-.16143,16.59877,16.59877,0,0,1-3.53611,2.23756,17.266,17.266,0,0,1-9.12588,1.33159c-8.05762-1.042-12.19512-7.694-12.49044-8.18028a17.37344,17.37344,0,0,1-2.348-10.45306A17.58886,17.58886,0,0,1,50.01746,37.6607a17.2369,17.2369,0,0,1,23.98672,2.9782c.27583.3532.5351.71814.7817,1.09286a1.37408,1.37408,0,0,0,1.75636.44321" style="fill:#ff6064" />
|
||||||
|
<g style="clip-path:url(#f19b29d4-1e8d-4178-855a-243e0912bbca)">
|
||||||
|
<rect x="18.97644" y="12.07838" width="17.75" height="17.76316" style="fill:#ff7c8c" />
|
||||||
|
<rect x="36.72644" y="12.07838" width="17.75" height="17.76316" style="fill:#ff4971" />
|
||||||
|
<rect x="54.47644" y="12.07838" width="17.75" height="17.76316" style="fill:#ff4457" />
|
||||||
|
<rect x="18.97644" y="29.84153" width="17.75" height="17.76316" style="fill:#ff4971" />
|
||||||
|
<rect x="18.97644" y="47.60469" width="17.75" height="17.76316" style="fill:#ff4457" />
|
||||||
|
<rect x="36.72644" y="29.84153" width="17.75" height="17.76316" style="fill:#ff4457" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<path d="M107.4542,59.23431a13.32324,13.32324,0,1,1,13.37961,13.48405A13.32259,13.32259,0,0,1,107.4542,59.23431m-13.90215,0a27.27746,27.27746,0,1,0,27.28176-27.48574A27.25288,27.25288,0,0,0,93.552,59.23431" style="fill:#ff6064" />
|
||||||
|
<g style="clip-path:url(#a709f1c7-d8a7-4e50-b69c-38e7e1bf269b)">
|
||||||
|
<rect x="93.55205" y="31.74857" width="54.55318" height="54.86696" style="fill:#ff6064" />
|
||||||
|
</g>
|
||||||
|
<g style="clip-path:url(#b9696296-277b-4b6c-8f78-1a1a4f0442c4)">
|
||||||
|
<rect x="153.41814" y="31.74857" width="86.74302" height="53.82666" style="fill:#ff6064" />
|
||||||
|
</g>
|
||||||
|
<g style="clip-path:url(#bb055267-959c-42d8-b7c8-a796232b4837)">
|
||||||
|
<rect x="244.38346" y="15.34274" width="35.1112" height="70.23249" style="fill:#ff6064" />
|
||||||
|
</g>
|
||||||
|
<g style="clip-path:url(#f8754a63-63bf-4125-8805-22d5c19b3f68)">
|
||||||
|
<rect x="285.67172" y="32.27121" width="57.89334" height="79.42594" style="fill:#ff6064" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 5.5 KiB |
BIN
packages/app/public/sounds/crash.wav
Normal file
BIN
packages/app/public/sounds/crash.wav
Normal file
Binary file not shown.
BIN
packages/app/public/sounds/error.wav
Normal file
BIN
packages/app/public/sounds/error.wav
Normal file
Binary file not shown.
BIN
packages/app/public/sounds/notification.wav
Normal file
BIN
packages/app/public/sounds/notification.wav
Normal file
Binary file not shown.
430
packages/app/src/App.jsx
Normal file
430
packages/app/src/App.jsx
Normal file
@ -0,0 +1,430 @@
|
|||||||
|
// Patch global prototypes
|
||||||
|
Array.prototype.findAndUpdateObject = function (discriminator, obj) {
|
||||||
|
let index = this.findIndex(item => item[discriminator] === obj[discriminator])
|
||||||
|
if (index !== -1) {
|
||||||
|
this[index] = obj
|
||||||
|
}
|
||||||
|
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.prototype.move = function (from, to) {
|
||||||
|
this.splice(to, 0, this.splice(from, 1)[0])
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
String.prototype.toTitleCase = function () {
|
||||||
|
return this.replace(/\w\S*/g, function (txt) {
|
||||||
|
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.tasked = function (promises) {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
let rejected = false
|
||||||
|
|
||||||
|
for await (let promise of promises) {
|
||||||
|
if (rejected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await promise()
|
||||||
|
} catch (error) {
|
||||||
|
rejected = true
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rejected) {
|
||||||
|
return resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
import React from "react"
|
||||||
|
import { CreateEviteApp, BindPropsProvider } from "evite"
|
||||||
|
import { Helmet } from "react-helmet"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { ActionSheet, Toast } from "antd-mobile"
|
||||||
|
import { StatusBar, Style } from "@capacitor/status-bar"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
|
||||||
|
import { Session, User } from "models"
|
||||||
|
import { API, SettingsController, Render, Splash, Theme, Sound, Notifications, i18n, Debug, Shortcuts } from "extensions"
|
||||||
|
import config from "config"
|
||||||
|
|
||||||
|
import { NotFound, RenderError, Crash, Settings, Navigation } from "components"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import Layout from "./layout"
|
||||||
|
import "theme/index.less"
|
||||||
|
|
||||||
|
const SplashExtension = Splash.extension({
|
||||||
|
logo: config.logo.alt,
|
||||||
|
preset: "fadeOut",
|
||||||
|
velocity: 1000,
|
||||||
|
props: {
|
||||||
|
logo: {
|
||||||
|
style: {
|
||||||
|
marginBottom: "10%",
|
||||||
|
stroke: "black",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
class App {
|
||||||
|
static initialize() {
|
||||||
|
window.app.version = config.package.version
|
||||||
|
|
||||||
|
this.mainSocket = this.contexts.app.WSInterface.sockets.main
|
||||||
|
this.loadingMessage = false
|
||||||
|
this.isAppCapacitor = () => navigator.userAgent === "capacitor"
|
||||||
|
}
|
||||||
|
|
||||||
|
static eventsHandlers = {
|
||||||
|
"new_session": async function () {
|
||||||
|
await this.flushState()
|
||||||
|
await this.initialization()
|
||||||
|
|
||||||
|
if (window.location.pathname == "/login") {
|
||||||
|
window.app.setLocation(this.beforeLoginLocation ?? "/main")
|
||||||
|
this.beforeLoginLocation = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"destroyed_session": async function () {
|
||||||
|
await this.flushState()
|
||||||
|
this.eventBus.emit("forceToLogin")
|
||||||
|
},
|
||||||
|
"forceToLogin": function () {
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
this.beforeLoginLocation = window.location.pathname
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.setLocation("/login")
|
||||||
|
},
|
||||||
|
"invalid_session": async function (error) {
|
||||||
|
await this.sessionController.forgetLocalSession()
|
||||||
|
await this.flushState()
|
||||||
|
|
||||||
|
if (window.location.pathname !== "/login") {
|
||||||
|
this.eventBus.emit("forceToLogin")
|
||||||
|
|
||||||
|
antd.notification.open({
|
||||||
|
message: <Translation>
|
||||||
|
{(t) => t("Invalid Session")}
|
||||||
|
</Translation>,
|
||||||
|
description: <Translation>
|
||||||
|
{(t) => t(error)}
|
||||||
|
</Translation>,
|
||||||
|
icon: <Icons.MdOutlineAccessTimeFilled />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clearAllOverlays": function () {
|
||||||
|
window.app.DrawerController.closeAll()
|
||||||
|
},
|
||||||
|
"websocket_connected": function () {
|
||||||
|
if (this.wsReconnecting) {
|
||||||
|
this.wsReconnectingTry = 0
|
||||||
|
this.wsReconnecting = false
|
||||||
|
this.initialization()
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
Toast.show({
|
||||||
|
icon: "success",
|
||||||
|
content: "Connected",
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"websocket_connection_error": function () {
|
||||||
|
if (!this.wsReconnecting) {
|
||||||
|
this.latencyWarning = null
|
||||||
|
this.wsReconnectingTry = 0
|
||||||
|
this.wsReconnecting = true
|
||||||
|
|
||||||
|
Toast.show({
|
||||||
|
icon: "loading",
|
||||||
|
content: "Connecting...",
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.wsReconnectingTry = this.wsReconnectingTry + 1
|
||||||
|
|
||||||
|
if (this.wsReconnectingTry > 3) {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"websocket_latency_too_high": function () {
|
||||||
|
if (!this.latencyWarning) {
|
||||||
|
this.latencyWarning = true
|
||||||
|
Toast.show({
|
||||||
|
icon: "loading",
|
||||||
|
content: "Slow connection...",
|
||||||
|
duration: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"websocket_latency_normal": function () {
|
||||||
|
if (this.latencyWarning) {
|
||||||
|
this.latencyWarning = null
|
||||||
|
Toast.show({
|
||||||
|
icon: "success",
|
||||||
|
content: "Connection restored",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appLoadError": function (error) {
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
static windowContext() {
|
||||||
|
return {
|
||||||
|
// TODO: Open with popup controller instead drawer controller
|
||||||
|
openNavigationMenu: () => window.app.DrawerController.open("navigation", Navigation),
|
||||||
|
openSettings: App.publicMethods.openSettings,
|
||||||
|
goMain: () => {
|
||||||
|
return window.app.setLocation(config.app.mainPath)
|
||||||
|
},
|
||||||
|
goToAccount: (username) => {
|
||||||
|
return window.app.setLocation(`/account`, { username })
|
||||||
|
},
|
||||||
|
setStatusBarStyleDark: async () => {
|
||||||
|
if (!this.isAppCapacitor()) {
|
||||||
|
console.warn("[App] setStatusBarStyleDark is only available on capacitor")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await StatusBar.setStyle({ style: Style.Dark })
|
||||||
|
},
|
||||||
|
setStatusBarStyleLight: async () => {
|
||||||
|
if (!this.isAppCapacitor()) {
|
||||||
|
console.warn("[App] setStatusBarStyleLight is not supported on this platform")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await StatusBar.setStyle({ style: Style.Light })
|
||||||
|
},
|
||||||
|
hideStatusBar: async () => {
|
||||||
|
if (!this.isAppCapacitor()) {
|
||||||
|
console.warn("[App] hideStatusBar is not supported on this platform")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await StatusBar.hide()
|
||||||
|
},
|
||||||
|
showStatusBar: async () => {
|
||||||
|
if (!this.isAppCapacitor()) {
|
||||||
|
console.warn("[App] showStatusBar is not supported on this platform")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await StatusBar.show()
|
||||||
|
},
|
||||||
|
isAppCapacitor: this.isAppCapacitor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static appContext() {
|
||||||
|
return {
|
||||||
|
renderRef: this.renderRef,
|
||||||
|
sessionController: this.sessionController,
|
||||||
|
userController: this.userController,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static staticRenders = {
|
||||||
|
NotFound: (props) => {
|
||||||
|
return <NotFound />
|
||||||
|
},
|
||||||
|
RenderError: (props) => {
|
||||||
|
return <RenderError {...props} />
|
||||||
|
},
|
||||||
|
Crash: Crash,
|
||||||
|
initialization: () => {
|
||||||
|
return <Splash.SplashComponent logo={config.logo.alt} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static publicMethods = {
|
||||||
|
"openSettings": (goTo) => {
|
||||||
|
window.app.DrawerController.open("settings", Settings, {
|
||||||
|
props: {
|
||||||
|
width: "fit-content",
|
||||||
|
},
|
||||||
|
componentProps: {
|
||||||
|
goTo,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionController = new Session()
|
||||||
|
userController = new User()
|
||||||
|
state = {
|
||||||
|
session: null,
|
||||||
|
user: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
flushState = async () => {
|
||||||
|
await this.setState({ session: null, user: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
if (this.isAppCapacitor()) {
|
||||||
|
window.addEventListener("statusTap", () => {
|
||||||
|
this.eventBus.emit("statusTap")
|
||||||
|
})
|
||||||
|
|
||||||
|
StatusBar.setOverlaysWebView({ overlay: true })
|
||||||
|
window.app.hideStatusBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgentPlatform = window.navigator.userAgent.toLowerCase()
|
||||||
|
|
||||||
|
if (userAgentPlatform.includes("mac")) {
|
||||||
|
window.app.ShortcutsController.register({
|
||||||
|
key: ",",
|
||||||
|
meta: true,
|
||||||
|
}, (...args) => {
|
||||||
|
App.publicMethods.openSettings(...args)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
window.app.ShortcutsController.register({
|
||||||
|
key: ",",
|
||||||
|
ctrl: true,
|
||||||
|
}, (...args) => {
|
||||||
|
App.publicMethods.openSettings(...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.eventBus.emit("render_initialization")
|
||||||
|
|
||||||
|
await this.initialization()
|
||||||
|
|
||||||
|
this.eventBus.emit("render_initialization_done")
|
||||||
|
}
|
||||||
|
|
||||||
|
initialization = async () => {
|
||||||
|
console.debug(`[App] Initializing app`)
|
||||||
|
|
||||||
|
const initializationTasks = [
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await this.contexts.app.attachAPIConnection()
|
||||||
|
} catch (error) {
|
||||||
|
throw {
|
||||||
|
cause: "Cannot connect to API",
|
||||||
|
details: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await this.__SessionInit()
|
||||||
|
} catch (error) {
|
||||||
|
throw {
|
||||||
|
cause: "Cannot initialize session",
|
||||||
|
details: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await this.__UserInit()
|
||||||
|
} catch (error) {
|
||||||
|
throw {
|
||||||
|
cause: "Cannot initialize user data",
|
||||||
|
details: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await this.__WSInit()
|
||||||
|
} catch (error) {
|
||||||
|
throw {
|
||||||
|
cause: "Cannot connect to WebSocket",
|
||||||
|
details: error.message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
await Promise.tasked(initializationTasks).catch((reason) => {
|
||||||
|
console.error(`[App] Initialization failed: ${reason.cause}`)
|
||||||
|
window.app.eventBus.emit("appLoadError", reason.cause, reason.details)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
__SessionInit = async () => {
|
||||||
|
const token = await Session.token
|
||||||
|
|
||||||
|
if (!token || token == null) {
|
||||||
|
window.app.eventBus.emit("forceToLogin")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.sessionController.getCurrentSession().catch((error) => {
|
||||||
|
console.error(`[App] Cannot get current session: ${error.message}`)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.setState({ session })
|
||||||
|
}
|
||||||
|
|
||||||
|
__WSInit = async () => {
|
||||||
|
if (!this.state.session) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await Session.token
|
||||||
|
await this.contexts.app.attachWSConnection()
|
||||||
|
|
||||||
|
this.mainSocket.emit("authenticate", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
__UserInit = async () => {
|
||||||
|
if (!this.state.session) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.data()
|
||||||
|
await this.setState({ user })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Helmet>
|
||||||
|
<title>{config.app.siteName}</title>
|
||||||
|
</Helmet>
|
||||||
|
<antd.ConfigProvider>
|
||||||
|
<Layout user={this.state.user} >
|
||||||
|
<BindPropsProvider
|
||||||
|
user={this.state.user}
|
||||||
|
session={this.state.session}
|
||||||
|
>
|
||||||
|
<Render.RouteRender staticRenders={App.staticRenders} />
|
||||||
|
</BindPropsProvider>
|
||||||
|
</Layout>
|
||||||
|
</antd.ConfigProvider>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateEviteApp(App, {
|
||||||
|
extensions: [
|
||||||
|
Shortcuts,
|
||||||
|
SettingsController,
|
||||||
|
i18n.extension,
|
||||||
|
Sound.extension,
|
||||||
|
Notifications.extension,
|
||||||
|
API,
|
||||||
|
Render.extension,
|
||||||
|
Theme.extension,
|
||||||
|
SplashExtension,
|
||||||
|
Debug,
|
||||||
|
],
|
||||||
|
})
|
80
packages/app/src/components/AboutApp/index.jsx
Normal file
80
packages/app/src/components/AboutApp/index.jsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React from "react"
|
||||||
|
import ReactDOM from "react-dom"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Card, Mask } from "antd-mobile"
|
||||||
|
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import { DiReact } from "react-icons/di"
|
||||||
|
|
||||||
|
import config from "config"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export const AboutCard = (props) => {
|
||||||
|
const [visible, setVisible] = React.useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setVisible(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setVisible(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
props.onClose()
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProduction = import.meta.env.PROD
|
||||||
|
const isWSMainConnected = window.app.ws.mainSocketConnected
|
||||||
|
const WSMainOrigin = app.ws.sockets.main.io.uri
|
||||||
|
|
||||||
|
return <Mask visible={visible} onMaskClick={() => close()}>
|
||||||
|
<div className="aboutApp_wrapper">
|
||||||
|
<Card
|
||||||
|
bodyClassName="aboutApp_card"
|
||||||
|
headerClassName="aboutApp_card_header"
|
||||||
|
title={
|
||||||
|
<div className="content">
|
||||||
|
<div className="branding">
|
||||||
|
<h2>{config.app.siteName}</h2>
|
||||||
|
<span>{config.author}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<antd.Tag><Icons.Tag />v{window.app.version ?? "experimental"}</antd.Tag>
|
||||||
|
<antd.Tag color={isProduction ? "green" : "magenta"}>
|
||||||
|
{isProduction ? <Icons.CheckCircle /> : <Icons.Triangle />}
|
||||||
|
{String(import.meta.env.MODE)}
|
||||||
|
</antd.Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="group">
|
||||||
|
<h3><Icons.Globe />Server information</h3>
|
||||||
|
<div>
|
||||||
|
<antd.Tag color={isWSMainConnected ? "green" : "red"}><Icons.Cpu />{WSMainOrigin}</antd.Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="group">
|
||||||
|
<h3><Icons.GitMerge />Versions</h3>
|
||||||
|
<div>
|
||||||
|
<antd.Tag color="#ffec3d">eVite v{window.__eviteVersion ?? "experimental"}</antd.Tag>
|
||||||
|
<antd.Tag color="#61DBFB"><DiReact /> v{React.version ?? "experimental"}</antd.Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Mask >
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openModal() {
|
||||||
|
const component = document.createElement("div")
|
||||||
|
document.body.appendChild(component)
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
ReactDOM.unmountComponentAtNode(component)
|
||||||
|
document.body.removeChild(component)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.render(<AboutCard onClose={onClose} />, component)
|
||||||
|
}
|
75
packages/app/src/components/AboutApp/index.less
Normal file
75
packages/app/src/components/AboutApp/index.less
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
.aboutApp_wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutApp_card {
|
||||||
|
height: fit-content;
|
||||||
|
width: 80vw;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.aboutApp_card_header {
|
||||||
|
.adm-card-header-title {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
h1,h2,h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.branding {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
h1,h2,h3 {
|
||||||
|
height: fit-content;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
height: fit-content;
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
27
packages/app/src/components/ActionsBar/index.jsx
Normal file
27
packages/app/src/components/ActionsBar/index.jsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from "react"
|
||||||
|
import classnames from "classnames"
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const { children } = props
|
||||||
|
|
||||||
|
return <div
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
padding: props.padding,
|
||||||
|
}}
|
||||||
|
className={classnames(
|
||||||
|
"actionsBar",
|
||||||
|
[props.mode],
|
||||||
|
{["transparent"]: props.type === "transparent"},
|
||||||
|
{["spaced"]: props.spaced},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={props.wrapperStyle}
|
||||||
|
className="wrapper"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
100
packages/app/src/components/ActionsBar/index.less
Normal file
100
packages/app/src/components/ActionsBar/index.less
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
@actionsBar_height: fit-content;
|
||||||
|
|
||||||
|
.actionsBar {
|
||||||
|
--ignore-dragger: true;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
overflow-x: overlay;
|
||||||
|
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: @actionsBar_height;
|
||||||
|
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background-color: #0c0c0c15;
|
||||||
|
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
--webkit-backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
transition: all 200ms ease-in-out;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin: 0 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
--ignore-dragger: true;
|
||||||
|
|
||||||
|
span {
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.float {
|
||||||
|
z-index: 1000;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fixedBottom {
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fixedTop {
|
||||||
|
z-index: 1000;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
backdrop-filter: none;
|
||||||
|
--webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.spaced {
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
258
packages/app/src/components/AddableSelectList/index.jsx
Normal file
258
packages/app/src/components/AddableSelectList/index.jsx
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { PullToRefresh } from "antd-mobile"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import { SelectableList, SwipeItem, Skeleton } from "components"
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import fuse from "fuse.js"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const statusRecord = {
|
||||||
|
pulling: "Slide down to refresh",
|
||||||
|
canRelease: "Release",
|
||||||
|
refreshing: <Icons.LoadingOutlined spin />,
|
||||||
|
complete: <Icons.Check />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddableSelectListSelector = (props = {}) => {
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [data, setData] = React.useState([])
|
||||||
|
const [searchValue, setSearchValue] = React.useState(null)
|
||||||
|
|
||||||
|
React.useEffect(async () => {
|
||||||
|
await fetchData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
if (typeof props.loadData === "function") {
|
||||||
|
const result = await props.loadData()
|
||||||
|
|
||||||
|
setData(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
if (typeof value.target?.value === "string") {
|
||||||
|
value = value.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
return setSearchValue(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const searcher = new fuse(data, {
|
||||||
|
includeScore: true,
|
||||||
|
keys: [...(props.searcherKeys ?? []), "_id", "name"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = searcher.search(value)
|
||||||
|
|
||||||
|
return setSearchValue(result.map((entry) => {
|
||||||
|
return entry.item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedSearch = debounce((value) => search(value), props.debounceSearchWait ?? 500)
|
||||||
|
|
||||||
|
const onSearch = (keyword) => {
|
||||||
|
if (typeof keyword !== "string") {
|
||||||
|
keyword = keyword.target.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyword === "" && searchValue) {
|
||||||
|
return setSearchValue(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedSearch(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExcludedId = (id) => {
|
||||||
|
if (!props.excludedSelectedKeys) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.excludedIds) {
|
||||||
|
return props.excludedIds.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const findData = (id) => {
|
||||||
|
return data.find((item) => {
|
||||||
|
return item._id === id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="addableSelectListSelector">
|
||||||
|
<div className="header">
|
||||||
|
<div>
|
||||||
|
<antd.Input.Search
|
||||||
|
placeholder={props.searchPlaceholder ?? "Search"}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onChange={onSearch}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PullToRefresh
|
||||||
|
renderText={status => {
|
||||||
|
return <div>{statusRecord[status]}</div>
|
||||||
|
}}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
>
|
||||||
|
<SelectableList
|
||||||
|
overrideSelectionEnabled
|
||||||
|
items={searchValue ?? data}
|
||||||
|
actions={[
|
||||||
|
<div call="onDone" key="done">
|
||||||
|
Done
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
events={{
|
||||||
|
onDone: (ctx, keys) => props.handleDone(keys, keys.map((key) => findData(key))),
|
||||||
|
}}
|
||||||
|
disabledKeys={props.excludedIds}
|
||||||
|
renderItem={(item) => {
|
||||||
|
return <div disabled={isExcludedId(item._id)} className="item">
|
||||||
|
<div><antd.Avatar shape="square" src={item.image} /></div>
|
||||||
|
<div><h1>{item.label}</h1></div>
|
||||||
|
</div>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PullToRefresh>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddableSelectListItem = (props) => {
|
||||||
|
const { item, actions, onClick, onDelete } = props
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (typeof onClick === "function") {
|
||||||
|
onClick(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (typeof onDelete === "function") {
|
||||||
|
onDelete(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SwipeItem
|
||||||
|
onDelete={handleDelete}
|
||||||
|
>
|
||||||
|
<antd.List.Item
|
||||||
|
key={item._id}
|
||||||
|
className="item"
|
||||||
|
>
|
||||||
|
<antd.List.Item.Meta
|
||||||
|
avatar={<antd.Avatar src={item.image} />}
|
||||||
|
title={item.label}
|
||||||
|
/>
|
||||||
|
</antd.List.Item>
|
||||||
|
</SwipeItem>
|
||||||
|
}
|
||||||
|
|
||||||
|
//@evite-components#OperatorsAssignments*mobile/desktop
|
||||||
|
export default class AddableSelectList extends React.Component {
|
||||||
|
state = {
|
||||||
|
selectedKeys: [],
|
||||||
|
selectedItems: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickAdd = async () => {
|
||||||
|
window.app.DrawerController.open("AddableSelectListSelector", AddableSelectListSelector, {
|
||||||
|
onDone: async (ctx, keys, data) => {
|
||||||
|
if (keys.length <= 0) {
|
||||||
|
ctx.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedKeys, selectedItems } = this.state
|
||||||
|
|
||||||
|
selectedKeys = [...selectedKeys, ...keys]
|
||||||
|
selectedItems = [...selectedItems, ...data]
|
||||||
|
|
||||||
|
await this.setState({ selectedKeys: selectedKeys, selectedItems: selectedItems })
|
||||||
|
|
||||||
|
if (typeof this.props.onSelectItem === "function") {
|
||||||
|
await this.props.onSelectItem(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.close()
|
||||||
|
},
|
||||||
|
componentProps: {
|
||||||
|
loadData: this.props.loadData,
|
||||||
|
searcherKeys: this.props.searcherKeys,
|
||||||
|
debounceSearchWait: this.props.debounceSearchWait,
|
||||||
|
excludedIds: this.state.selectedKeys,
|
||||||
|
excludedSelectedKeys: this.props.excludedSelectedKeys,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickItem = async (item) => {
|
||||||
|
if (typeof this.props.onClickItem === "function") {
|
||||||
|
await this.props.onClickItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteItem = async (item) => {
|
||||||
|
if (typeof this.props.onDeleteItem === "function") {
|
||||||
|
await this.props.onDeleteItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { selectedKeys, selectedItems } = this.state
|
||||||
|
|
||||||
|
const newSelectedKeys = selectedKeys.filter((key) => {
|
||||||
|
return key !== item._id
|
||||||
|
})
|
||||||
|
|
||||||
|
const newSelectedItems = selectedItems.filter((_item) => {
|
||||||
|
return _item._id !== item._id
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ selectedKeys: newSelectedKeys, selectedItems: newSelectedItems })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="addableSelectList">
|
||||||
|
<div>
|
||||||
|
<antd.List
|
||||||
|
dataSource={this.state.selectedItems}
|
||||||
|
renderItem={(item) => {
|
||||||
|
return <AddableSelectListItem
|
||||||
|
item={item}
|
||||||
|
actions={this.props.actions}
|
||||||
|
onClick={() => this.onClickItem(item)}
|
||||||
|
onDelete={() => { this.onDeleteItem(item) }}
|
||||||
|
/>
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<div key="add">
|
||||||
|
<antd.Button
|
||||||
|
icon={<Icons.Plus />}
|
||||||
|
shape="round"
|
||||||
|
onClick={this.onClickAdd}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
45
packages/app/src/components/AddableSelectList/index.less
Normal file
45
packages/app/src/components/AddableSelectList/index.less
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
.addableSelectListSelector {
|
||||||
|
> div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addableSelectList {
|
||||||
|
#delete {
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
204
packages/app/src/components/AdminTools/UserDataManager/index.jsx
Normal file
204
packages/app/src/components/AdminTools/UserDataManager/index.jsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import debounce from "lodash/debounce"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
|
||||||
|
import { ActionsBar } from "components"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export const EditAccountField = ({ id, component, props, header, handleChange, delay, defaultValue, allowEmpty }) => {
|
||||||
|
const [currentValue, setCurrentValue] = React.useState(defaultValue)
|
||||||
|
const [emittedValue, setEmittedValue] = React.useState(null)
|
||||||
|
|
||||||
|
const debouncedHandleChange = React.useCallback(
|
||||||
|
debounce((value) => handleChange({ id, value }), delay ?? 300),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDiscard = (event) => {
|
||||||
|
if (typeof event !== "undefined") {
|
||||||
|
event.target.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentValue(defaultValue)
|
||||||
|
handleChange({ id, value: defaultValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
debouncedHandleChange(currentValue)
|
||||||
|
}, [emittedValue])
|
||||||
|
|
||||||
|
const onChange = (event) => {
|
||||||
|
event.persist()
|
||||||
|
let { value } = event.target
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
if (value.length === 0) {
|
||||||
|
// if is not allowed to be empty, discard modifications
|
||||||
|
if (!allowEmpty) {
|
||||||
|
return handleDiscard(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentValue(value)
|
||||||
|
setEmittedValue(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event) => {
|
||||||
|
if (event.keyCode === 27) {
|
||||||
|
// "escape" pressed, reset to default value
|
||||||
|
handleDiscard(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderComponent = component
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={id} className="edit_account_field">
|
||||||
|
{header ? header : null}
|
||||||
|
<RenderComponent {...props} value={currentValue} id={id} onChange={onChange} onKeyDown={handleKeyDown} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UserDataManager extends React.Component {
|
||||||
|
state = {
|
||||||
|
data: this.props.user,
|
||||||
|
changes: [],
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
if (!this.props.user && this.props.userId) {
|
||||||
|
// TODO: Fetch from API
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSave = async () => {
|
||||||
|
if (!Array.isArray(this.state.changes)) {
|
||||||
|
antd.message.error("Something went wrong")
|
||||||
|
console.error("Changes should be an array")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.setState({ loading: true })
|
||||||
|
const update = {}
|
||||||
|
|
||||||
|
this.state.changes.forEach((change) => {
|
||||||
|
update[change.id] = change.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await this.api.post.updateUser({ _id: this.state.data._id, update }).catch((err) => {
|
||||||
|
antd.message.error(err.message)
|
||||||
|
console.error(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.setState({ changes: [], loading: false })
|
||||||
|
|
||||||
|
if (typeof this.props.onSave === "function") {
|
||||||
|
await this.props.onSave(this.state.changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
if (typeof this.props.handleDone === "function") {
|
||||||
|
this.props.handleDone(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange = (event) => {
|
||||||
|
const { id, value } = event
|
||||||
|
let changes = [...this.state.changes]
|
||||||
|
|
||||||
|
changes = changes.filter((change) => change.id !== id)
|
||||||
|
|
||||||
|
if (this.state.data[id] !== value) {
|
||||||
|
changes.push({ id, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className="edit_account">
|
||||||
|
<div className="edit_account_wrapper">
|
||||||
|
<div className="edit_account_category">
|
||||||
|
<h2>
|
||||||
|
<Icons.User /> Account information
|
||||||
|
</h2>
|
||||||
|
<EditAccountField
|
||||||
|
id="username"
|
||||||
|
defaultValue={this.state.data.username}
|
||||||
|
header={
|
||||||
|
<div>
|
||||||
|
<Icons.Tag /> Username
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
component={antd.Input}
|
||||||
|
props={{
|
||||||
|
placeholder: "Username",
|
||||||
|
disabled: true,
|
||||||
|
}}
|
||||||
|
handleChange={this.handleChange}
|
||||||
|
/>
|
||||||
|
<EditAccountField
|
||||||
|
id="fullName"
|
||||||
|
defaultValue={this.state.data.fullName}
|
||||||
|
header={
|
||||||
|
<div>
|
||||||
|
<Icons.User /> Name
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
component={antd.Input}
|
||||||
|
props={{
|
||||||
|
placeholder: "Your full name",
|
||||||
|
}}
|
||||||
|
handleChange={this.handleChange}
|
||||||
|
/>
|
||||||
|
<EditAccountField
|
||||||
|
id="email"
|
||||||
|
defaultValue={this.state.data.email}
|
||||||
|
header={
|
||||||
|
<div>
|
||||||
|
<Icons.Mail /> Email
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
component={antd.Input}
|
||||||
|
props={{
|
||||||
|
placeholder: "Your email address",
|
||||||
|
type: "email",
|
||||||
|
}}
|
||||||
|
handleChange={this.handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionsBar spaced>
|
||||||
|
<div>
|
||||||
|
{this.state.changes.length} <Translation>
|
||||||
|
{(t) => t("Changes")}
|
||||||
|
</Translation>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
loading={this.state.loading}
|
||||||
|
disabled={this.state.loading}
|
||||||
|
onClick={this.handleSave}
|
||||||
|
>
|
||||||
|
<Translation>
|
||||||
|
{(t) => t("Save")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
</ActionsBar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
.edit_account{
|
||||||
|
overflow: hidden!important;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit_account_wrapper{
|
||||||
|
overflow: scroll;
|
||||||
|
> div {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit_account_actions {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px 0 20px 0;
|
||||||
|
background-color: rgba(221, 221, 221, 0.5);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
--webkit-backdrop-filter: blur(2px);
|
||||||
|
border-radius: 18px 18px 0 0;
|
||||||
|
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit_account_actions_indicator{
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
margin-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit_account_category{
|
||||||
|
> div {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit_account_field {
|
||||||
|
input:not(:focus){
|
||||||
|
color:rgba(105, 105, 105, 0.5)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,140 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
|
||||||
|
import { ActionsBar, UserSelector, Skeleton } from "components"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default class UserRolesManager extends React.Component {
|
||||||
|
state = {
|
||||||
|
users: null,
|
||||||
|
roles: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
await this.fetchRoles()
|
||||||
|
|
||||||
|
if (typeof this.props.id !== "undefined") {
|
||||||
|
const ids = Array.isArray(this.props.id) ? this.props.id : [this.props.id]
|
||||||
|
await this.fetchUsersData(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRoles = async () => {
|
||||||
|
const result = await this.api.get.roles().catch((err) => {
|
||||||
|
antd.message.error(err)
|
||||||
|
console.error(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.setState({ roles: result })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUsersData = async (users) => {
|
||||||
|
const result = await this.api.get.users(undefined, { _id: users }).catch((err) => {
|
||||||
|
antd.message.error(err)
|
||||||
|
console.error(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.setState({
|
||||||
|
users: result.map((data) => {
|
||||||
|
return {
|
||||||
|
_id: data._id,
|
||||||
|
username: data.username,
|
||||||
|
roles: data.roles,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelectUser = async (users) => {
|
||||||
|
this.fetchUsersData(users)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRoleChange = (userId, role, to) => {
|
||||||
|
let updatedUsers = this.state.users.map((user) => {
|
||||||
|
if (user._id === userId) {
|
||||||
|
if (to == true) {
|
||||||
|
user.roles.push(role)
|
||||||
|
} else {
|
||||||
|
user.roles = user.roles.filter((r) => r !== role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ users: updatedUsers })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit = async () => {
|
||||||
|
const update = this.state.users.map((data) => {
|
||||||
|
return {
|
||||||
|
_id: data._id,
|
||||||
|
roles: data.roles,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await this.api.post.updateUserRoles({ update }).catch((err) => {
|
||||||
|
antd.message.error(err)
|
||||||
|
console.error(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.props.handleDone(result)
|
||||||
|
if (typeof this.props.close === "function") {
|
||||||
|
this.props.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem = (item) => {
|
||||||
|
return <div className="grantRoles_user">
|
||||||
|
<h2>
|
||||||
|
<Icons.User /> {item.username}
|
||||||
|
</h2>
|
||||||
|
<div className="roles">
|
||||||
|
{this.state.roles.map((role) => {
|
||||||
|
return <antd.Checkbox
|
||||||
|
key={role.name}
|
||||||
|
checked={item.roles.includes(role.name)}
|
||||||
|
onChange={(to) => this.handleRoleChange(item._id, role.name, to.target.checked)}
|
||||||
|
>
|
||||||
|
{role.name}
|
||||||
|
</antd.Checkbox>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { users } = this.state
|
||||||
|
|
||||||
|
if (!users) {
|
||||||
|
return <UserSelector handleDone={this.handleSelectUser} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
{users.map((data) => {
|
||||||
|
return this.renderItem(data)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<ActionsBar>
|
||||||
|
<div>
|
||||||
|
<antd.Button icon={<Icons.Save />} onClick={() => this.handleSubmit()}>
|
||||||
|
Submit
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
</ActionsBar>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
.grantRoles_user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.roles {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
41
packages/app/src/components/AdminTools/index.js
Normal file
41
packages/app/src/components/AdminTools/index.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import UserDataManager from "./UserDataManager"
|
||||||
|
import UserRolesManager from "./UserRolesManager"
|
||||||
|
|
||||||
|
export { UserDataManager, UserRolesManager }
|
||||||
|
|
||||||
|
export const open = {
|
||||||
|
dataManager: (user) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.app.DrawerController.open("UserDataManager", UserDataManager, {
|
||||||
|
componentProps: {
|
||||||
|
user: user,
|
||||||
|
},
|
||||||
|
onDone: (ctx, value) => {
|
||||||
|
resolve(value)
|
||||||
|
ctx.close()
|
||||||
|
},
|
||||||
|
onFail: (ctx, value) => {
|
||||||
|
reject(value)
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
rolesManager: (id) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.app.DrawerController.open("UserRolesManager", UserRolesManager, {
|
||||||
|
componentProps: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
onDone: (ctx, value) => {
|
||||||
|
resolve(value)
|
||||||
|
ctx.close()
|
||||||
|
},
|
||||||
|
onFail: (ctx, value) => {
|
||||||
|
reject(value)
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
61
packages/app/src/components/AppSearcher/index.jsx
Normal file
61
packages/app/src/components/AppSearcher/index.jsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
class Results extends React.Component {
|
||||||
|
state = {
|
||||||
|
results: this.props.results ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults = () => {
|
||||||
|
return this.state.results.map(result => {
|
||||||
|
return <div id={result.id}>
|
||||||
|
{result.title}
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div>
|
||||||
|
{this.renderResults()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppSearcher extends React.Component {
|
||||||
|
state = {
|
||||||
|
loading: false,
|
||||||
|
searchResult: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearch = (value) => {
|
||||||
|
let results = []
|
||||||
|
|
||||||
|
// get results
|
||||||
|
results.push({ id: value, title: value })
|
||||||
|
|
||||||
|
// storage results
|
||||||
|
this.setState({ searchResult: results })
|
||||||
|
|
||||||
|
// open results onlayout drawer
|
||||||
|
this.openResults()
|
||||||
|
}
|
||||||
|
|
||||||
|
openResults = () => {
|
||||||
|
window.app.SidedrawerController.render(() => <Results results={this.state.searchResult} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<antd.Input.Search
|
||||||
|
style={{ width: this.props.width }}
|
||||||
|
className="search_bar"
|
||||||
|
placeholder="Search on app..."
|
||||||
|
loading={this.state.loading}
|
||||||
|
onSearch={this.handleSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
34
packages/app/src/components/AppSearcher/index.less
Normal file
34
packages/app/src/components/AppSearcher/index.less
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
@import "theme/index.less";
|
||||||
|
|
||||||
|
.search_bar {
|
||||||
|
user-select: none;
|
||||||
|
--webkit-user-select: none;
|
||||||
|
|
||||||
|
height: fit-content;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
background-color: var(--background-color-accent) !important;
|
||||||
|
border-color: var(--background-color-accent) !important;
|
||||||
|
color: var(--background-color-contrast) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-group-addon {
|
||||||
|
width: fit-content;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn {
|
||||||
|
background-color: var(--background-color-primary) !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
17
packages/app/src/components/Clock/index.jsx
Normal file
17
packages/app/src/components/Clock/index.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [time, setTime] = React.useState(new Date())
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTime(new Date())
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div className="clock">{time.toLocaleTimeString()}</div>
|
||||||
|
}
|
23
packages/app/src/components/Crash/index.jsx
Normal file
23
packages/app/src/components/Crash/index.jsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Result, Button } from "antd"
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const { crash } = props
|
||||||
|
|
||||||
|
return <div className="app_crash_wrapper">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Crash"
|
||||||
|
subTitle={crash.message}
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="reload" onClick={() => window.location.reload()}>
|
||||||
|
Reload app
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<code>{crash.error}</code>
|
||||||
|
</div>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
}
|
15
packages/app/src/components/DraggableDrawer/helpers.js
Normal file
15
packages/app/src/components/DraggableDrawer/helpers.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export function isDirectionTop(direction) {
|
||||||
|
return direction === "top";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectionBottom(direction) {
|
||||||
|
return direction === "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectionLeft(direction) {
|
||||||
|
return direction === "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDirectionRight(direction) {
|
||||||
|
return direction === "right";
|
||||||
|
}
|
442
packages/app/src/components/DraggableDrawer/index.jsx
Normal file
442
packages/app/src/components/DraggableDrawer/index.jsx
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
// © Jack Hanford https://github.com/hanford/react-drag-drawer
|
||||||
|
import React, { Component } from "react";
|
||||||
|
import { Motion, spring, presets } from "react-motion";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import document from "global/document";
|
||||||
|
import Observer from "react-intersection-observer";
|
||||||
|
import { css } from "@emotion/css";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isDirectionBottom,
|
||||||
|
isDirectionTop,
|
||||||
|
isDirectionLeft,
|
||||||
|
isDirectionRight,
|
||||||
|
} from "./helpers.js"
|
||||||
|
|
||||||
|
export default class Drawer extends Component {
|
||||||
|
static propTypes = {
|
||||||
|
open: PropTypes.bool.isRequired,
|
||||||
|
children: PropTypes.oneOfType([
|
||||||
|
PropTypes.object,
|
||||||
|
PropTypes.array,
|
||||||
|
PropTypes.element
|
||||||
|
]),
|
||||||
|
onRequestClose: PropTypes.func,
|
||||||
|
onDrag: PropTypes.func,
|
||||||
|
onOpen: PropTypes.func,
|
||||||
|
inViewportChange: PropTypes.func,
|
||||||
|
allowClose: PropTypes.bool,
|
||||||
|
notifyWillClose: PropTypes.func,
|
||||||
|
direction: PropTypes.string,
|
||||||
|
modalElementClass: PropTypes.oneOfType([
|
||||||
|
PropTypes.object,
|
||||||
|
PropTypes.string
|
||||||
|
]),
|
||||||
|
containerOpacity: PropTypes.number,
|
||||||
|
containerElementClass: PropTypes.string,
|
||||||
|
getContainerRef: PropTypes.func,
|
||||||
|
getModalRef: PropTypes.func
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
notifyWillClose: () => { },
|
||||||
|
onOpen: () => { },
|
||||||
|
onDrag: () => { },
|
||||||
|
inViewportChange: () => { },
|
||||||
|
onRequestClose: () => { },
|
||||||
|
getContainerRef: () => { },
|
||||||
|
getModalRef: () => { },
|
||||||
|
containerOpacity: 0.6,
|
||||||
|
direction: "bottom",
|
||||||
|
parentElement: document.body,
|
||||||
|
allowClose: true,
|
||||||
|
dontApplyListeners: false,
|
||||||
|
containerElementClass: "",
|
||||||
|
modalElementClass: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
ignore: false,
|
||||||
|
onRange: false,
|
||||||
|
open: this.props.open,
|
||||||
|
thumb: 0,
|
||||||
|
start: 0,
|
||||||
|
position: 0,
|
||||||
|
touching: false,
|
||||||
|
listenersAttached: false
|
||||||
|
}
|
||||||
|
|
||||||
|
DRAGGER_HEIGHT_SIZE = 100
|
||||||
|
MAX_NEGATIVE_SCROLL = 5
|
||||||
|
SCROLL_TO_CLOSE = 475
|
||||||
|
ALLOW_DRAWER_TRANSFORM = true
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, nextState) {
|
||||||
|
// in the process of closing the drawer
|
||||||
|
if (!this.props.open && prevProps.open) {
|
||||||
|
this.removeListeners()
|
||||||
|
|
||||||
|
setTimeout(this.setState({ open: false }), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.drawer) {
|
||||||
|
this.getNegativeScroll(this.drawer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// in the process of opening the drawer
|
||||||
|
if (this.props.open && !prevProps.open) {
|
||||||
|
this.props.onOpen()
|
||||||
|
|
||||||
|
this.setState({ open: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
attachListeners = drawer => {
|
||||||
|
const { dontApplyListeners, getModalRef, direction } = this.props
|
||||||
|
const { listenersAttached } = this.state
|
||||||
|
|
||||||
|
// only attach listeners once as this function gets called every re-render
|
||||||
|
if (!drawer || listenersAttached || dontApplyListeners) return
|
||||||
|
|
||||||
|
this.drawer = drawer
|
||||||
|
|
||||||
|
getModalRef(drawer)
|
||||||
|
|
||||||
|
this.drawer.addEventListener("touchend", this.release)
|
||||||
|
this.drawer.addEventListener("touchmove", this.drag)
|
||||||
|
this.drawer.addEventListener("touchstart", this.tap)
|
||||||
|
|
||||||
|
let position = 0
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
position = drawer.scrollWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ listenersAttached: true, position }, () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// trigger reflow so webkit browsers calculate height properly 😔
|
||||||
|
// https://bugs.webkit.org/show_bug.cgi?id=184905
|
||||||
|
this.drawer.style.display = "none"
|
||||||
|
void this.drawer.offsetHeight
|
||||||
|
this.drawer.style.display = ""
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
isThumbInDraggerRange = (event) => {
|
||||||
|
return (event.touches[0].clientY - this.drawer.getBoundingClientRect().top) < this.DRAGGER_HEIGHT_SIZE
|
||||||
|
}
|
||||||
|
|
||||||
|
removeListeners = () => {
|
||||||
|
if (!this.drawer) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawer.removeEventListener("touchend", this.release)
|
||||||
|
this.drawer.removeEventListener("touchmove", this.drag)
|
||||||
|
this.drawer.removeEventListener("touchstart", this.tap)
|
||||||
|
|
||||||
|
this.setState({ listenersAttached: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
tap = event => {
|
||||||
|
const { pageY, pageX } = event.touches[0]
|
||||||
|
const shouldIgnored = Boolean(event.target.getAttribute("ignore-dragger") || (window.getComputedStyle(event.target).getPropertyValue("--ignore-dragger") !== ""))
|
||||||
|
|
||||||
|
const start = isDirectionBottom(this.props.direction) || isDirectionTop(this.props.direction) ? pageY : pageX
|
||||||
|
|
||||||
|
// reset NEW_POSITION and MOVING_POSITION
|
||||||
|
this.NEW_POSITION = 0
|
||||||
|
this.MOVING_POSITION = 0
|
||||||
|
|
||||||
|
this.setState({ ignore: shouldIgnored, onRange: this.isThumbInDraggerRange(event), thumb: start, start: start, touching: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
drag = event => {
|
||||||
|
if (this.state.ignore) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const { direction } = this.props
|
||||||
|
const { thumb, position } = this.state
|
||||||
|
const { pageY, pageX } = event.touches[0]
|
||||||
|
|
||||||
|
const movingPosition = isDirectionBottom(direction) || isDirectionTop(direction) ? pageY : pageX
|
||||||
|
const delta = movingPosition - thumb
|
||||||
|
const newPosition = isDirectionBottom(direction) ? position + delta : position - delta
|
||||||
|
|
||||||
|
if (newPosition > 0 && this.ALLOW_DRAWER_TRANSFORM) {
|
||||||
|
// stop android's pull to refresh behavior
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.props.onDrag({ newPosition })
|
||||||
|
|
||||||
|
// we set this, so we can access it in shouldWeCloseDrawer. Since setState is async, we're not guranteed we'll have the
|
||||||
|
// value in time
|
||||||
|
this.MOVING_POSITION = movingPosition
|
||||||
|
this.NEW_POSITION = newPosition
|
||||||
|
|
||||||
|
let positionThreshold = 0
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
positionThreshold = this.drawer.scrollWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPosition < positionThreshold && this.shouldWeCloseDrawer()) {
|
||||||
|
this.props.notifyWillClose(true)
|
||||||
|
} else {
|
||||||
|
this.props.notifyWillClose(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// not at the bottom
|
||||||
|
if (this.NEGATIVE_SCROLL < newPosition) {
|
||||||
|
this.setState({
|
||||||
|
thumb: movingPosition,
|
||||||
|
position:
|
||||||
|
positionThreshold > 0
|
||||||
|
? Math.min(newPosition, positionThreshold)
|
||||||
|
: newPosition
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
release = (event) => {
|
||||||
|
const { direction } = this.props
|
||||||
|
|
||||||
|
this.setState({ touching: false })
|
||||||
|
|
||||||
|
if (this.shouldWeCloseDrawer() && this.state.onRange) {
|
||||||
|
this.props.onRequestClose(this)
|
||||||
|
} else {
|
||||||
|
let newPosition = 0
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
newPosition = this.drawer.scrollWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ position: newPosition })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getNegativeScroll = (element) => {
|
||||||
|
const { direction } = this.props
|
||||||
|
const size = this.getElementSize()
|
||||||
|
|
||||||
|
if (isDirectionBottom(direction) || isDirectionTop(direction)) {
|
||||||
|
this.NEGATIVE_SCROLL = size - element.scrollHeight - this.MAX_NEGATIVE_SCROLL
|
||||||
|
} else {
|
||||||
|
this.NEGATIVE_SCROLL = size - element.scrollWidth - this.MAX_NEGATIVE_SCROLL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDrawer = () => {
|
||||||
|
const { allowClose, direction } = this.props
|
||||||
|
|
||||||
|
let defaultPosition = 0
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
defaultPosition = this.drawer.scrollWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowClose === false) {
|
||||||
|
// if we aren't going to allow close, let's animate back to the default position
|
||||||
|
return this.setState({
|
||||||
|
position: defaultPosition,
|
||||||
|
thumb: 0,
|
||||||
|
touching: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
open: false,
|
||||||
|
position: defaultPosition,
|
||||||
|
touching: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
this.removeListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldWeCloseDrawer = () => {
|
||||||
|
const { start: touchStart } = this.state
|
||||||
|
const { direction } = this.props
|
||||||
|
|
||||||
|
let initialPosition = 0
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
initialPosition = this.drawer.scrollWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.MOVING_POSITION === initialPosition) return false
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
return (
|
||||||
|
this.NEW_POSITION < initialPosition &&
|
||||||
|
this.MOVING_POSITION - touchStart > this.SCROLL_TO_CLOSE
|
||||||
|
)
|
||||||
|
} else if (isDirectionLeft(direction)) {
|
||||||
|
return (
|
||||||
|
this.NEW_POSITION >= initialPosition &&
|
||||||
|
touchStart - this.MOVING_POSITION > this.SCROLL_TO_CLOSE
|
||||||
|
)
|
||||||
|
} else if (isDirectionTop(direction)) {
|
||||||
|
return (
|
||||||
|
this.NEW_POSITION >= initialPosition &&
|
||||||
|
touchStart - this.MOVING_POSITION > this.SCROLL_TO_CLOSE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
this.NEW_POSITION >= initialPosition &&
|
||||||
|
this.MOVING_POSITION - touchStart > this.SCROLL_TO_CLOSE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDrawerTransform = (value) => {
|
||||||
|
const { direction } = this.props
|
||||||
|
|
||||||
|
if (isDirectionBottom(direction)) {
|
||||||
|
return { transform: `translate3d(0, ${value}px, 0)` }
|
||||||
|
} else if (isDirectionTop(direction)) {
|
||||||
|
return { transform: `translate3d(0, -${value}px, 0)` }
|
||||||
|
} else if (isDirectionLeft(direction)) {
|
||||||
|
return { transform: `translate3d(-${Math.abs(value)}px, 0, 0)` }
|
||||||
|
} else if (isDirectionRight(direction)) {
|
||||||
|
return { transform: `translate3d(${value}px, 0, 0)` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementSize = () => {
|
||||||
|
return isDirectionBottom(this.props.direction) || isDirectionTop(this.props.direction) ? window.innerHeight : window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
getPosition(hiddenPosition) {
|
||||||
|
const { position } = this.state
|
||||||
|
const { direction } = this.props
|
||||||
|
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
return hiddenPosition - position
|
||||||
|
} else {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inViewportChange = (inView) => {
|
||||||
|
this.props.inViewportChange(inView)
|
||||||
|
|
||||||
|
this.ALLOW_DRAWER_TRANSFORM = inView
|
||||||
|
}
|
||||||
|
|
||||||
|
preventDefault = (event) => event.preventDefault()
|
||||||
|
stopPropagation = (event) => event.stopPropagation()
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
containerElementClass,
|
||||||
|
containerOpacity,
|
||||||
|
dontApplyListeners,
|
||||||
|
id,
|
||||||
|
getContainerRef,
|
||||||
|
getModalRef,
|
||||||
|
direction
|
||||||
|
} = this.props
|
||||||
|
|
||||||
|
const open = this.state.open && this.props.open
|
||||||
|
|
||||||
|
// If drawer isn't open or in the process of opening/closing, then remove it from the DOM
|
||||||
|
// also, if we're not client side we need to return early because createPortal is only
|
||||||
|
// a clientside method
|
||||||
|
|
||||||
|
// if ((!this.state.open && !this.props.open)) {
|
||||||
|
// return null
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { touching } = this.state
|
||||||
|
|
||||||
|
const springPreset = isDirectionLeft(direction) ? { damping: 17, stiffness: 120 } : { damping: 20, stiffness: 300 }
|
||||||
|
const animationSpring = touching ? springPreset : presets.stiff
|
||||||
|
const hiddenPosition = this.getElementSize()
|
||||||
|
const position = this.getPosition(hiddenPosition)
|
||||||
|
|
||||||
|
// Style object for the container element
|
||||||
|
let containerStyle = {
|
||||||
|
backgroundColor: `rgba(55, 56, 56, ${open ? containerOpacity : 0})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If direction is right, we set the overflowX property to 'hidden' to hide the x scrollbar during
|
||||||
|
// the sliding animation
|
||||||
|
if (isDirectionRight(direction)) {
|
||||||
|
containerStyle = {
|
||||||
|
...containerStyle,
|
||||||
|
overflowX: "hidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<Motion
|
||||||
|
style={{
|
||||||
|
translate: spring(open ? position : hiddenPosition, animationSpring)
|
||||||
|
}}
|
||||||
|
defaultStyle={{
|
||||||
|
translate: hiddenPosition
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ translate }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
style={containerStyle}
|
||||||
|
onClick={this.props.onRequestClose}
|
||||||
|
className={`${Container} ${containerElementClass} `}
|
||||||
|
ref={getContainerRef}
|
||||||
|
>
|
||||||
|
<Observer
|
||||||
|
className={HaveWeScrolled}
|
||||||
|
onChange={this.inViewportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={this.stopPropagation}
|
||||||
|
style={this.getDrawerTransform(translate)}
|
||||||
|
ref={this.attachListeners}
|
||||||
|
className={this.props.modalElementClass || ""}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Motion>,
|
||||||
|
this.props.parentElement
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = css`
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 11;
|
||||||
|
transition: background-color 0.2s linear;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const HaveWeScrolled = css`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
height: 1px;
|
||||||
|
width: 100%;
|
||||||
|
`
|
29
packages/app/src/components/Icons/index.jsx
Normal file
29
packages/app/src/components/Icons/index.jsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
// import icons lib
|
||||||
|
import * as lib1 from "feather-reactjs"
|
||||||
|
import * as lib2 from "react-icons/md"
|
||||||
|
import * as lib3 from "@ant-design/icons"
|
||||||
|
|
||||||
|
const marginedStyle = { width: "1em", height: "1em", marginRight: "10px", verticalAlign: "-0.125em" }
|
||||||
|
|
||||||
|
const customs = {
|
||||||
|
verifiedBadge: () => <svg style={marginedStyle} xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"> <path d="M23 12l-2.44-2.78.34-3.68-3.61-.82-1.89-3.18L12 3 8.6 1.54 6.71 4.72l-3.61.81.34 3.68L1 12l2.44 2.78-.34 3.69 3.61.82 1.89 3.18L12 21l3.4 1.46 1.89-3.18 3.61-.82-.34-3.68L23 12m-13 5l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"></path></svg>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
...customs,
|
||||||
|
...lib1,
|
||||||
|
...lib2,
|
||||||
|
...lib3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIconRender(icon, props) {
|
||||||
|
if (typeof Icons[icon] !== "undefined") {
|
||||||
|
return React.createElement(Icons[icon], props)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Icons
|
90
packages/app/src/components/ImageUploader/index.jsx
Normal file
90
packages/app/src/components/ImageUploader/index.jsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { getBase64 } from "utils"
|
||||||
|
|
||||||
|
export default class ImageUploader extends React.Component {
|
||||||
|
state = {
|
||||||
|
previewVisible: false,
|
||||||
|
previewImage: "",
|
||||||
|
previewTitle: "",
|
||||||
|
fileList: [],
|
||||||
|
urlList: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
handleChange = ({ fileList }) => {
|
||||||
|
this.setState({ fileList })
|
||||||
|
|
||||||
|
if (typeof this.props.onChange === "function") {
|
||||||
|
this.props.onChange(fileList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancel = () => this.setState({ previewVisible: false })
|
||||||
|
|
||||||
|
handlePreview = async file => {
|
||||||
|
if (!file.url && !file.preview) {
|
||||||
|
file.preview = await getBase64(file.originFileObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
previewImage: file.url || file.preview,
|
||||||
|
previewVisible: true,
|
||||||
|
previewTitle: file.name || file.url.substring(file.url.lastIndexOf("/") + 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUploadRequest = async (req) => {
|
||||||
|
if (typeof this.props.onUpload === "function") {
|
||||||
|
this.props.onUpload(req)
|
||||||
|
} else {
|
||||||
|
const payloadData = new FormData()
|
||||||
|
payloadData.append(req.file.name, req.file)
|
||||||
|
|
||||||
|
const result = await this.api.post.upload(payloadData).catch(() => {
|
||||||
|
req.onError("Error uploading image")
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
req.onSuccess()
|
||||||
|
await this.setState({ urlList: [...this.state.urlList, ...result.urls] })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.props.onUploadDone === "function") {
|
||||||
|
await this.props.onUploadDone(this.state.urlList)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.urls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const uploadButton = (<div>
|
||||||
|
<Icons.Plus />
|
||||||
|
<div style={{ marginTop: 8 }}>Upload</div>
|
||||||
|
</div>)
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<antd.Upload
|
||||||
|
listType="picture-card"
|
||||||
|
fileList={this.state.fileList}
|
||||||
|
onPreview={this.handlePreview}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
customRequest={this.handleUploadRequest}
|
||||||
|
>
|
||||||
|
{this.state.fileList.length >= 8 ? null : uploadButton}
|
||||||
|
</antd.Upload>
|
||||||
|
<antd.Modal
|
||||||
|
visible={this.state.previewVisible}
|
||||||
|
title={this.state.previewTitle}
|
||||||
|
footer={null}
|
||||||
|
onCancel={this.handleCancel}
|
||||||
|
>
|
||||||
|
<img style={{ width: "100%" }} src={this.state.previewImage} />
|
||||||
|
</antd.Modal>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
54
packages/app/src/components/ImageViewer/index.jsx
Normal file
54
packages/app/src/components/ImageViewer/index.jsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Swiper } from "antd-mobile"
|
||||||
|
import { LazyLoadImage } from "react-lazy-load-image-component"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import "react-lazy-load-image-component/src/effects/blur.css"
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ImageViewer = (props) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!Array.isArray(props.src)) {
|
||||||
|
props.src = [props.src]
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openViewer = () => {
|
||||||
|
if (props.extended) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.DrawerController.open("ImageViewer", ImageViewer, {
|
||||||
|
componentProps: {
|
||||||
|
src: props.src,
|
||||||
|
extended: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={classnames("ImageViewer", { ["extended"]: props.extended })}>
|
||||||
|
<Swiper>
|
||||||
|
{props.src.map((image) => {
|
||||||
|
return <Swiper.Item
|
||||||
|
onClick={() => {
|
||||||
|
openViewer(image)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LazyLoadImage
|
||||||
|
src={image}
|
||||||
|
effect="blur"
|
||||||
|
wrapperClassName="image-wrapper"
|
||||||
|
onClick={() => {
|
||||||
|
openViewer()
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.src = "/broken-image.svg"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Swiper.Item>
|
||||||
|
})}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImageViewer
|
31
packages/app/src/components/ImageViewer/index.less
Normal file
31
packages/app/src/components/ImageViewer/index.less
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
.ImageViewer {
|
||||||
|
--ignore-dragger: true;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: 8px;
|
||||||
|
--ignore-dragger: true;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 25vh;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.extended {
|
||||||
|
height: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-height: 100vh;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
48
packages/app/src/components/LikeButton/index.jsx
Normal file
48
packages/app/src/components/LikeButton/index.jsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from "react"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const [liked, setLiked] = React.useState(props.defaultLiked ?? false)
|
||||||
|
const [clicked, setCliked] = React.useState(false)
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
let to = !liked
|
||||||
|
|
||||||
|
setCliked(to)
|
||||||
|
|
||||||
|
if (typeof props.onClick === "function") {
|
||||||
|
const result = await props.onClick(to)
|
||||||
|
if (typeof result === "boolean") {
|
||||||
|
to = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLiked(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button
|
||||||
|
className={classnames("likeButton", { ["clicked"]: liked })}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
"ripple",
|
||||||
|
{ ["clicked"]: clicked }
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
|
<svg
|
||||||
|
className={classnames(
|
||||||
|
"heart",
|
||||||
|
{ ["liked"]: liked },
|
||||||
|
{ ["clicked"]: clicked },
|
||||||
|
)}
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
151
packages/app/src/components/LikeButton/index.less
Normal file
151
packages/app/src/components/LikeButton/index.less
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
@color-heart : #EA442B;
|
||||||
|
@likeAnimationDuration : .5s;
|
||||||
|
@likeAnimationEasing : cubic-bezier(.7, 0, .3, 1);
|
||||||
|
|
||||||
|
.likeButton {
|
||||||
|
display : flex;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.ripple,
|
||||||
|
.ripple:before,
|
||||||
|
.ripple:after {
|
||||||
|
position : relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
font-size : 40px;
|
||||||
|
border : none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width : 1em;
|
||||||
|
height : 1em;
|
||||||
|
padding : 0;
|
||||||
|
margin : 0;
|
||||||
|
outline : none;
|
||||||
|
z-index : 2;
|
||||||
|
transition : transform @likeAnimationDuration @likeAnimationEasing;
|
||||||
|
cursor : pointer;
|
||||||
|
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
z-index : -1;
|
||||||
|
content : '';
|
||||||
|
position : absolute;
|
||||||
|
top : 0;
|
||||||
|
left : 0;
|
||||||
|
width : 100%;
|
||||||
|
height : 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
transition : inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
content : '';
|
||||||
|
position : absolute;
|
||||||
|
top : 0;
|
||||||
|
left : 0;
|
||||||
|
width : 100%;
|
||||||
|
height : 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heart {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>path {
|
||||||
|
stroke-width: 2;
|
||||||
|
transition : fill @likeAnimationDuration @likeAnimationEasing;
|
||||||
|
stroke : currentColor;
|
||||||
|
fill : transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.liked {
|
||||||
|
>path {
|
||||||
|
stroke: var(--primaryColor);
|
||||||
|
fill : var(--primaryColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.clicked {
|
||||||
|
animation: heart-bounce @likeAnimationDuration @likeAnimationEasing;
|
||||||
|
|
||||||
|
@keyframes heart-bounce {
|
||||||
|
40% {
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ripple {
|
||||||
|
position : absolute;
|
||||||
|
height : 1em;
|
||||||
|
width : 1em;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow : hidden;
|
||||||
|
z-index : 1;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content : '';
|
||||||
|
position : absolute;
|
||||||
|
top : 0;
|
||||||
|
left : 0;
|
||||||
|
width : 100%;
|
||||||
|
height : 100%;
|
||||||
|
border : .4em solid var(--primaryColor);
|
||||||
|
border-radius: inherit;
|
||||||
|
transform : scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.clicked {
|
||||||
|
&:before {
|
||||||
|
animation: ripple-out @likeAnimationDuration @likeAnimationEasing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple-out {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scale(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes depress {
|
||||||
|
|
||||||
|
from,
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(5%) scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes depress-shadow {
|
||||||
|
|
||||||
|
from,
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
}
|
76
packages/app/src/components/Navigation/index.jsx
Normal file
76
packages/app/src/components/Navigation/index.jsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
|
||||||
|
import Items from "schemas/routes.json"
|
||||||
|
import { Icons, createIconRender } from "components/Icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default class NavigationMenu extends React.Component {
|
||||||
|
onClick = (id) => {
|
||||||
|
window.app.setLocation(`/${id}`)
|
||||||
|
this.props.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
generateMenus = (items) => {
|
||||||
|
// group items it has children to a new array and the rest to a general array
|
||||||
|
items = items.reduce((acc, item) => {
|
||||||
|
if (item.children) {
|
||||||
|
acc.push(item)
|
||||||
|
} else {
|
||||||
|
acc[0].children.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [{
|
||||||
|
id: "general",
|
||||||
|
title: "General",
|
||||||
|
icon: "Home",
|
||||||
|
children: []
|
||||||
|
}])
|
||||||
|
|
||||||
|
return items.map((group) => {
|
||||||
|
return <div key={group.id} className="group">
|
||||||
|
<h2>
|
||||||
|
{Icons[group.icon] && createIconRender(group.icon)}
|
||||||
|
<Translation>
|
||||||
|
{(t) => t(group.title)}
|
||||||
|
</Translation>
|
||||||
|
</h2>
|
||||||
|
<div className="items">
|
||||||
|
{
|
||||||
|
group.children.map((item) => {
|
||||||
|
return this.renderItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem = (item, index) => {
|
||||||
|
return <div
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
onClick={() => this.onClick(item.id)}
|
||||||
|
className="item"
|
||||||
|
>
|
||||||
|
<div className="icon">
|
||||||
|
{Icons[item.icon] && createIconRender(item.icon)}
|
||||||
|
</div>
|
||||||
|
<div className="name">
|
||||||
|
<h1>
|
||||||
|
<Translation>
|
||||||
|
{(t) => t(item.title ?? item.id)}
|
||||||
|
</Translation>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="navigation">
|
||||||
|
{this.generateMenus(Items)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
81
packages/app/src/components/Navigation/index.less
Normal file
81
packages/app/src/components/Navigation/index.less
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
@buttonSize: 26vw;
|
||||||
|
@buttonBorderRadius: 8px;
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.items {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
--ignore-dragger: true;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
|
||||||
|
width: @buttonSize;
|
||||||
|
min-width: @buttonSize;
|
||||||
|
height: @buttonSize;
|
||||||
|
min-height: @buttonSize;
|
||||||
|
|
||||||
|
border-radius: @buttonBorderRadius;
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
transition: all 80ms ease-in-out;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
svg {
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
word-break: break-all;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
padding-top: 10px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
word-break: break-all;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0!important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:active {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
8
packages/app/src/components/Notifications/index.jsx
Normal file
8
packages/app/src/components/Notifications/index.jsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { notification } from "antd"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
error: (...context) => {
|
||||||
|
notification.error(context)
|
||||||
|
},
|
||||||
|
}
|
91
packages/app/src/components/ObjectInspector/index.jsx
Normal file
91
packages/app/src/components/ObjectInspector/index.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { decycle } from "@corenode/utils"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
function parseTreeData(data, backKey) {
|
||||||
|
const keys = Object.keys(data)
|
||||||
|
let result = Array()
|
||||||
|
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const value = data[key]
|
||||||
|
const valueType = typeof value
|
||||||
|
const obj = Object()
|
||||||
|
|
||||||
|
obj.key = backKey ? `${backKey}-${key}` : key
|
||||||
|
obj.title = key
|
||||||
|
obj.type = valueType
|
||||||
|
|
||||||
|
if (valueType === "object") {
|
||||||
|
obj.children = parseTreeData(value)
|
||||||
|
} else {
|
||||||
|
obj.children = [
|
||||||
|
{
|
||||||
|
key: `${obj.key}-value`,
|
||||||
|
title: "value",
|
||||||
|
icon: <Icons.Box />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: `${obj.key}-value-indicator`,
|
||||||
|
title: String(value),
|
||||||
|
icon: <Icons.Box />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: `${obj.key}-type`,
|
||||||
|
title: "type",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: `${obj.key}-type-indicator`,
|
||||||
|
title: valueType,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(obj)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class ObjectInspector extends React.Component {
|
||||||
|
state = {
|
||||||
|
data: null,
|
||||||
|
expandedKeys: [],
|
||||||
|
autoExpandParent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const raw = decycle(this.props.data)
|
||||||
|
const data = parseTreeData(raw)
|
||||||
|
|
||||||
|
this.setState({ raw, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
onExpand = (expandedKeys) => {
|
||||||
|
this.setState({
|
||||||
|
expandedKeys,
|
||||||
|
autoExpandParent: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { expandedKeys, autoExpandParent } = this.state
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<antd.Tree
|
||||||
|
//showLine
|
||||||
|
|
||||||
|
switcherIcon={<Icons.DownOutlined />}
|
||||||
|
onExpand={this.onExpand}
|
||||||
|
expandedKeys={expandedKeys}
|
||||||
|
autoExpandParent={autoExpandParent}
|
||||||
|
treeData={this.state.data}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
246
packages/app/src/components/PostCard/index.jsx
Normal file
246
packages/app/src/components/PostCard/index.jsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import { LikeButton } from "components"
|
||||||
|
import moment from "moment"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import { User } from "models"
|
||||||
|
|
||||||
|
import CSSMotion from "rc-animate/lib/CSSMotion"
|
||||||
|
import useLayoutEffect from "rc-util/lib/hooks/useLayoutEffect"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const getCurrentHeight = (node) => ({ height: node.offsetHeight })
|
||||||
|
|
||||||
|
const getMaxHeight = (node) => {
|
||||||
|
return { height: node.scrollHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCollapsedHeight = () => ({ height: 0, opacity: 0 })
|
||||||
|
|
||||||
|
function PostHeader(props) {
|
||||||
|
const [timeAgo, setTimeAgo] = React.useState(0)
|
||||||
|
|
||||||
|
const goToProfile = () => {
|
||||||
|
window.app.goToAccount(props.postData.user?.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTimeAgo = () => {
|
||||||
|
setTimeAgo(moment(props.postData.created_at ?? "").fromNow())
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
updateTimeAgo()
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
updateTimeAgo()
|
||||||
|
}, 10000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [props.postData.created_at])
|
||||||
|
|
||||||
|
return <div className="postHeader">
|
||||||
|
<div className="userInfo">
|
||||||
|
<div className="avatar">
|
||||||
|
<antd.Avatar src={props.postData.user?.avatar} />
|
||||||
|
</div>
|
||||||
|
<div className="info">
|
||||||
|
<div>
|
||||||
|
<h1 onClick={goToProfile}>
|
||||||
|
{props.postData.user?.fullName ?? `@${props.postData.user?.username}`}
|
||||||
|
{props.postData.user?.verified && <Icons.verifiedBadge />}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{timeAgo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="postStadistics">
|
||||||
|
<div className="item">
|
||||||
|
<Icons.Heart className={classnames("icon", { ["filled"]: props.isLiked })} />
|
||||||
|
<div className="value">
|
||||||
|
{props.likes}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="item">
|
||||||
|
<Icons.MessageSquare />
|
||||||
|
<div className="value">
|
||||||
|
{props.comments}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostContent({ message }) {
|
||||||
|
return <div className="content">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostActions(props) {
|
||||||
|
return <div className="actions">
|
||||||
|
<div className="action" id="likes">
|
||||||
|
<div className="icon">
|
||||||
|
<LikeButton defaultLiked={props.defaultLiked} onClick={props.onClickLike} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action" id="comments" onClick={props.onClickComments}>
|
||||||
|
<div className="icon">
|
||||||
|
<Icons.MessageSquare className="icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action" id="save" onClick={props.onClickSave}>
|
||||||
|
<div className="icon">
|
||||||
|
<Icons.Bookmark />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{props.isSelf && <div className="action" id="selfMenu" onClick={props.onClickSelfMenu}>
|
||||||
|
<div className="icon">
|
||||||
|
<Icons.MoreVertical />
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PostCard extends React.Component {
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
selfId: null,
|
||||||
|
data: this.props.data,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
const selfId = await User.selfUserId()
|
||||||
|
|
||||||
|
window.app.ws.listen(`like.post.${this.props.data._id}`, async (data) => {
|
||||||
|
await this.setState({ data })
|
||||||
|
})
|
||||||
|
window.app.ws.listen(`unlike.post.${this.props.data._id}`, async (data) => {
|
||||||
|
await this.setState({ data })
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.setState({
|
||||||
|
selfId,
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickLike = async (to) => {
|
||||||
|
let result = false
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
const apiResult = await await this.api.put.like({ post_id: this.props.data._id })
|
||||||
|
result = apiResult.success
|
||||||
|
} else {
|
||||||
|
const apiResult = await await this.api.put.unlike({ post_id: this.props.data._id })
|
||||||
|
result = apiResult.success
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickSave = async () => {
|
||||||
|
// TODO: save post
|
||||||
|
}
|
||||||
|
|
||||||
|
hasLiked = () => {
|
||||||
|
return this.state.data.likes.some(user_id => user_id === this.state.selfId)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelf = () => {
|
||||||
|
return this.state.selfId === this.state.data.user._id
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const hasLiked = this.hasLiked()
|
||||||
|
|
||||||
|
if (this.state.loading) {
|
||||||
|
return <antd.Skeleton active />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div
|
||||||
|
id={this.props.data._id}
|
||||||
|
key={this.props.data._id}
|
||||||
|
className={classnames("postCard", { ["liked"]: hasLiked })}
|
||||||
|
>
|
||||||
|
<div className="wrapper">
|
||||||
|
<PostHeader
|
||||||
|
postData={this.props.data}
|
||||||
|
isLiked={hasLiked}
|
||||||
|
onClickLike={() => this.onClickLike(false)}
|
||||||
|
onClickSave={this.onClickSave}
|
||||||
|
likes={this.state.data.likes.length}
|
||||||
|
comments={this.state.data.comments.length}
|
||||||
|
/>
|
||||||
|
<PostContent
|
||||||
|
message={this.props.data.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="actionsIndicatorWrapper">
|
||||||
|
<div className="actionsIndicator">
|
||||||
|
<Icons.MoreHorizontal />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actionsWrapper">
|
||||||
|
<PostActions
|
||||||
|
onClickLike={this.onClickLike}
|
||||||
|
defaultLiked={hasLiked}
|
||||||
|
isSelf={this.isSelf()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PostCardAnimated = ({
|
||||||
|
data,
|
||||||
|
onAppear,
|
||||||
|
motionAppear,
|
||||||
|
}, ref,) => {
|
||||||
|
const motionRef = React.useRef(false)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (motionRef.current) {
|
||||||
|
onAppear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <CSSMotion
|
||||||
|
ref={ref}
|
||||||
|
motionName="motion"
|
||||||
|
motionAppear={motionAppear}
|
||||||
|
onAppearStart={getCollapsedHeight}
|
||||||
|
onAppearActive={node => {
|
||||||
|
motionRef.current = true;
|
||||||
|
return getMaxHeight(node);
|
||||||
|
}}
|
||||||
|
onAppearEnd={onAppear}
|
||||||
|
onLeaveStart={getCurrentHeight}
|
||||||
|
onLeaveActive={getCollapsedHeight}
|
||||||
|
onLeaveEnd={() => {
|
||||||
|
onLeave(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(props, passedMotionRef) => {
|
||||||
|
return <PostCard
|
||||||
|
ref={passedMotionRef}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
}}
|
||||||
|
</CSSMotion>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ForwardedPostCardAnimated = React.forwardRef(PostCardAnimated)
|
||||||
|
|
||||||
|
export default ForwardedPostCardAnimated
|
295
packages/app/src/components/PostCard/index.less
Normal file
295
packages/app/src/components/PostCard/index.less
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
.postCard {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width : 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
filter: drop-shadow(3px 3px 2px var(--shadow-color));
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
border-radius : 8px;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
outline-width: 1px;
|
||||||
|
outline-style: solid;
|
||||||
|
outline-color: transparent;
|
||||||
|
|
||||||
|
&.liked {
|
||||||
|
filter : drop-shadow(0px 0px 2px var(--primaryColor));
|
||||||
|
outline-color: var(--primaryColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items : center;
|
||||||
|
|
||||||
|
width : 100%;
|
||||||
|
padding: 17px;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
.postHeader {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction : row;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items : center;
|
||||||
|
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill : var(--appColor);
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction : column;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: start;
|
||||||
|
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
margin : 0;
|
||||||
|
font-family: "DM Mono", monospace;
|
||||||
|
align-self : start;
|
||||||
|
cursor : pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.postStadistics {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-size : 16px;
|
||||||
|
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display : inline-flex;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height : fit-content;
|
||||||
|
|
||||||
|
margin-left : 20px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
&.filled {
|
||||||
|
color: var(--primaryColor);
|
||||||
|
fill : var(--primaryColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-family: "DM Mono", monospace;
|
||||||
|
font-size : 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items : flex-start;
|
||||||
|
|
||||||
|
//background-color: var(--background-color-primary);
|
||||||
|
|
||||||
|
padding : 0 10px 10px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
font-size : 14px;
|
||||||
|
font-family: "Poppins", sans-serif;
|
||||||
|
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
|
||||||
|
overflow : hidden;
|
||||||
|
word-break : break-all;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsIndicatorWrapper {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsIndicator {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width : 10vw;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
border-radius : 8px 8px 0 0;
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsWrapper {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
bottom : 0;
|
||||||
|
left : 0;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
width : 100%;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
margin-top: 15px;
|
||||||
|
padding : 10px;
|
||||||
|
|
||||||
|
border-radius: 8px;
|
||||||
|
transition : all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction : row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
width: 80%;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
|
||||||
|
.action {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
cursor : pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
position: absolute;
|
||||||
|
bottom : 0;
|
||||||
|
|
||||||
|
font-size : 14px;
|
||||||
|
font-family: "DM Mono", monospace;
|
||||||
|
|
||||||
|
transform : translate(0, 50%);
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:hover {
|
||||||
|
.icon {
|
||||||
|
svg {
|
||||||
|
color: var(--primaryColor) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
border-radius: 360px;
|
||||||
|
width : 55px;
|
||||||
|
height : 55px;
|
||||||
|
|
||||||
|
font-size: 20px;
|
||||||
|
padding : 2px;
|
||||||
|
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
transform : translate(0, -15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.postCard:hover {
|
||||||
|
.wrapper {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsWrapper {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .actionsIndicator {
|
||||||
|
// opacity: 0;
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeActionsIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeActionOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
98
packages/app/src/components/PostCreator/index.jsx
Normal file
98
packages/app/src/components/PostCreator/index.jsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import { User } from "models"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const maxMessageLength = 512
|
||||||
|
|
||||||
|
const PostCreatorInput = (props) => {
|
||||||
|
const [value, setValue] = React.useState("")
|
||||||
|
|
||||||
|
const canPublish = () => {
|
||||||
|
return value.length !== 0 && value.length < maxMessageLength
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (canPublish()) {
|
||||||
|
if (typeof props.onSubmit === "function") {
|
||||||
|
props.onSubmit(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="textInput">
|
||||||
|
<div className="avatar">
|
||||||
|
<img src={props.user?.avatar} />
|
||||||
|
</div>
|
||||||
|
<antd.Input.TextArea
|
||||||
|
//className={classnames("textArea", { ["active"]: canPublish() })}
|
||||||
|
disabled={props.loading}
|
||||||
|
value={value}
|
||||||
|
onPressEnter={handleSubmit}
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
dragable="false"
|
||||||
|
placeholder="What are you thinking?"
|
||||||
|
onChange={onChange}
|
||||||
|
allowClear
|
||||||
|
rows={8}
|
||||||
|
maxLength={maxMessageLength}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<antd.Button
|
||||||
|
type="primary"
|
||||||
|
disabled={props.loading || !canPublish()}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
icon={props.loading ? <Icons.LoadingOutlined spin /> : <Icons.Send />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PostCreator extends React.Component {
|
||||||
|
state = {
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
const userData = await User.data()
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
userData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit = async (value) => {
|
||||||
|
await this.setState({ loading: true })
|
||||||
|
|
||||||
|
const result = this.api.put.post({
|
||||||
|
message: value,
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(error)
|
||||||
|
antd.message.error(error)
|
||||||
|
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="postCreator">
|
||||||
|
<PostCreatorInput
|
||||||
|
user={this.state.userData}
|
||||||
|
loading={this.state.loading}
|
||||||
|
onSubmit={this.onSubmit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
81
packages/app/src/components/PostCreator/index.less
Normal file
81
packages/app/src/components/PostCreator/index.less
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
.postCreator {
|
||||||
|
width : 100%;
|
||||||
|
padding : 15px;
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
|
max-width : 600px;
|
||||||
|
border-radius: 7px;
|
||||||
|
|
||||||
|
.textInput {
|
||||||
|
display : flex;
|
||||||
|
width : 100%;
|
||||||
|
transition : height 150ms ease-in-out;
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width : fit-content;
|
||||||
|
height: 45px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width : 45px;
|
||||||
|
height : 45px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textArea {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
transition : all 150ms ease-in-out !important;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--background-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-primary {
|
||||||
|
z-index : 10;
|
||||||
|
position : relative;
|
||||||
|
border-radius : 0 10px 10px 0;
|
||||||
|
height : 100%;
|
||||||
|
vertical-align: bottom;
|
||||||
|
border : none;
|
||||||
|
box-shadow : none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input {
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
|
||||||
|
z-index : 10;
|
||||||
|
position : relative;
|
||||||
|
border-color : transparent !important;
|
||||||
|
box-shadow : none;
|
||||||
|
border-radius: 3px 0 0;
|
||||||
|
height : 100%;
|
||||||
|
padding : 5px 10px;
|
||||||
|
transition : height 150ms linear;
|
||||||
|
width : 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-btn-primary[disabled] {
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-input-affix-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
100
packages/app/src/components/PostsFeed/index.jsx
Normal file
100
packages/app/src/components/PostsFeed/index.jsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { PostCard } from "components"
|
||||||
|
|
||||||
|
import List from "rc-virtual-list"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
|
||||||
|
export default class PostsFeed extends React.Component {
|
||||||
|
state = {
|
||||||
|
initialLoading: true,
|
||||||
|
list: [],
|
||||||
|
animating: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
listRef = React.createRef()
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
await this.loadPosts()
|
||||||
|
|
||||||
|
window.app.ws.listen(`new.post`, async (data) => {
|
||||||
|
this.onInsert(data)
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.setState({
|
||||||
|
initialLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosts = async ({
|
||||||
|
startIndex,
|
||||||
|
stopIndex,
|
||||||
|
} = {}) => {
|
||||||
|
const result = await this.api.get.feed(undefined, {
|
||||||
|
startIndex,
|
||||||
|
stopIndex,
|
||||||
|
feedLength: this.props.feedLength,
|
||||||
|
user_id: this.props.fromUserId,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(result)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.setState({ list: result })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAppear = (...args) => {
|
||||||
|
console.log('Appear:', args)
|
||||||
|
this.setState({ animating: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
lockForAnimation = () => {
|
||||||
|
this.setState({ animating: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
onInsert = async (data) => {
|
||||||
|
const updatedList = this.state.list
|
||||||
|
|
||||||
|
updatedList.unshift(data)
|
||||||
|
|
||||||
|
await this.setState({
|
||||||
|
list: updatedList,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.lockForAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.initialLoading) {
|
||||||
|
return <antd.Skeleton active />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.list.length === 0) {
|
||||||
|
return <antd.Empty />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div
|
||||||
|
className="postsFeed"
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
ref={this.listRef}
|
||||||
|
data={this.state.list}
|
||||||
|
height="80vh"
|
||||||
|
itemHeight="100%"
|
||||||
|
className="content"
|
||||||
|
>
|
||||||
|
{(item, index) => (
|
||||||
|
<PostCard
|
||||||
|
data={item}
|
||||||
|
motionAppear={this.state.animating && index === 0}
|
||||||
|
onAppear={this.onAppear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
41
packages/app/src/components/PostsFeed/index.less
Normal file
41
packages/app/src/components/PostsFeed/index.less
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
.postsFeed {
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width : 100%;
|
||||||
|
max-width: 40vw;
|
||||||
|
|
||||||
|
.rc-virtual-list-holder {
|
||||||
|
overflow-y: visible !important;
|
||||||
|
width : 100%;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-virtual-list-holder-inner {
|
||||||
|
width : 100%;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
display : flex;
|
||||||
|
flex-direction : column;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width : 100%;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
73
packages/app/src/components/RenderError/index.jsx
Normal file
73
packages/app/src/components/RenderError/index.jsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Result, Button, Typography } from "antd"
|
||||||
|
import { CloseCircleOutlined } from "@ant-design/icons"
|
||||||
|
import config from "config"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const { Paragraph, Text } = Typography
|
||||||
|
|
||||||
|
const ErrorEntry = (props) => {
|
||||||
|
const { error } = props
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
return <div className="error">
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
Unhandled error
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="error">
|
||||||
|
<CloseCircleOutlined />
|
||||||
|
{error.info.toString()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
let errors = []
|
||||||
|
|
||||||
|
if (Array.isArray(props.error)) {
|
||||||
|
errors = props.error
|
||||||
|
} else {
|
||||||
|
errors.push(props.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickGoMain = () => {
|
||||||
|
window.app.setLocation(config.app.mainPath ?? "/main")
|
||||||
|
}
|
||||||
|
const onClickReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Render Error"
|
||||||
|
subTitle="It seems that the application is having problems displaying this page, we have detected some unrecoverable errors due to a bug. (This error should be automatically reported to the developers to find a solution as soon as possible)"
|
||||||
|
extra={[
|
||||||
|
<Button type="primary" key="gomain" onClick={onClickGoMain}>
|
||||||
|
Go Main
|
||||||
|
</Button>,
|
||||||
|
<Button key="reload" onClick={onClickReload}>Reload</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Paragraph>
|
||||||
|
<Text
|
||||||
|
strong
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
We catch the following errors:
|
||||||
|
</Text>
|
||||||
|
<div className="errors">
|
||||||
|
{errors.map((error, index) => {
|
||||||
|
return <ErrorEntry key={index} error={error} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Paragraph>
|
||||||
|
</Result>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
16
packages/app/src/components/RenderError/index.less
Normal file
16
packages/app/src/components/RenderError/index.less
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.errors {
|
||||||
|
margin-top: 12px;
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: red;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
margin-left: 24px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
196
packages/app/src/components/RenderWindow/index.jsx
Normal file
196
packages/app/src/components/RenderWindow/index.jsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import React from "react"
|
||||||
|
import ReactDOM from "react-dom"
|
||||||
|
import { Rnd } from "react-rnd"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
class DOMWindow {
|
||||||
|
constructor(props) {
|
||||||
|
this.props = { ...props }
|
||||||
|
|
||||||
|
this.id = this.props.id
|
||||||
|
this.key = 0
|
||||||
|
|
||||||
|
this.root = document.getElementById("app_windows")
|
||||||
|
this.element = document.getElementById(this.id)
|
||||||
|
|
||||||
|
// handle root container
|
||||||
|
if (!this.root) {
|
||||||
|
this.root = document.createElement("div")
|
||||||
|
this.root.setAttribute("id", "app_windows")
|
||||||
|
|
||||||
|
document.body.append(this.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
// get all windows opened has container
|
||||||
|
const rootNodes = this.root.childNodes
|
||||||
|
|
||||||
|
// ensure this window has last key from rootNode
|
||||||
|
if (rootNodes.length > 0) {
|
||||||
|
const lastChild = rootNodes[rootNodes.length - 1]
|
||||||
|
const lastChildKey = Number(lastChild.getAttribute("key"))
|
||||||
|
|
||||||
|
this.key = lastChildKey + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
this.element = document.createElement("div")
|
||||||
|
this.element.setAttribute("id", this.id)
|
||||||
|
this.element.setAttribute("key", this.key)
|
||||||
|
|
||||||
|
this.root.appendChild(this.element)
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (fragment) => {
|
||||||
|
ReactDOM.render(
|
||||||
|
fragment,
|
||||||
|
this.element,
|
||||||
|
)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
create = () => {
|
||||||
|
// set render
|
||||||
|
this.render(<WindowRender {...this.props} id={this.id} key={this.key} destroy={this.destroy} />)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy = () => {
|
||||||
|
this.element.remove()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WindowRender extends React.Component {
|
||||||
|
state = {
|
||||||
|
actions: [],
|
||||||
|
dimensions: {
|
||||||
|
height: this.props.height ?? 600,
|
||||||
|
width: this.props.width ?? 400,
|
||||||
|
},
|
||||||
|
position: this.props.defaultPosition,
|
||||||
|
visible: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount = () => {
|
||||||
|
this.setDefaultActions()
|
||||||
|
|
||||||
|
if (typeof this.props.actions !== "undefined") {
|
||||||
|
if (Array.isArray(this.props.actions)) {
|
||||||
|
const actions = this.state.actions ?? []
|
||||||
|
|
||||||
|
this.props.actions.forEach((action) => {
|
||||||
|
actions.push(action)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ actions })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.position) {
|
||||||
|
this.setState({ position: this.getCenterPosition() })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.toogleVisibility(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
toogleVisibility = (to) => {
|
||||||
|
this.setState({ visible: to ?? !this.state.visible })
|
||||||
|
}
|
||||||
|
|
||||||
|
getCenterPosition = () => {
|
||||||
|
const dimensions = this.state?.dimensions ?? {}
|
||||||
|
|
||||||
|
const windowHeight = dimensions.height ?? 600
|
||||||
|
const windowWidth = dimensions.width ?? 400
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: window.innerWidth / 2 - windowWidth / 2,
|
||||||
|
y: window.innerHeight / 2 - windowHeight / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaultActions = () => {
|
||||||
|
const { actions } = this.state
|
||||||
|
|
||||||
|
actions.push({
|
||||||
|
key: "close",
|
||||||
|
render: () => <Icons.XCircle style={{ margin: 0, padding: 0 }} />,
|
||||||
|
onClick: () => {
|
||||||
|
this.props.destroy()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ actions })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActions = () => {
|
||||||
|
const actions = this.state.actions
|
||||||
|
|
||||||
|
if (Array.isArray(actions)) {
|
||||||
|
return actions.map((action) => {
|
||||||
|
return (
|
||||||
|
<div key={action.key} onClick={action.onClick} {...action.props}>
|
||||||
|
{React.isValidElement(action.render) ? action.render : React.createElement(action.render)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getComponentRender = () => {
|
||||||
|
return React.isValidElement(this.props.children)
|
||||||
|
? React.cloneElement(this.props.children, this.props.renderProps)
|
||||||
|
: React.createElement(this.props.children, this.props.renderProps)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { position, dimensions, visible } = this.state
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Rnd
|
||||||
|
default={{
|
||||||
|
...position,
|
||||||
|
...dimensions,
|
||||||
|
}}
|
||||||
|
onResize={(e, direction, ref, delta, position) => {
|
||||||
|
this.setState({
|
||||||
|
dimensions: {
|
||||||
|
width: ref.offsetWidth,
|
||||||
|
height: ref.offsetHeight,
|
||||||
|
},
|
||||||
|
position,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
dragHandleClassName="window_topbar"
|
||||||
|
minWidth={this.props.minWidth ?? "300px"}
|
||||||
|
minHeight={this.props.minHeight ?? "200px"}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: dimensions.height,
|
||||||
|
width: dimensions.width,
|
||||||
|
}}
|
||||||
|
className="window_wrapper"
|
||||||
|
>
|
||||||
|
<div className="window_topbar">
|
||||||
|
<div className="title">{this.props.id}</div>
|
||||||
|
<div className="actions">{this.renderActions()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="window_body">{this.getComponentRender()}</div>
|
||||||
|
</div>
|
||||||
|
</Rnd>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DOMWindow, WindowRender }
|
93
packages/app/src/components/RenderWindow/index.less
Normal file
93
packages/app/src/components/RenderWindow/index.less
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
@wrapper_background: rgba(255, 255, 255, 1);
|
||||||
|
|
||||||
|
@topbar_height: 30px;
|
||||||
|
@topbar_background: rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
.window_wrapper {
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
background-color: @wrapper_background;
|
||||||
|
border: 1px solid rgba(161, 133, 133, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.translucid {
|
||||||
|
border: unset;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
--webkit-backdrop-filter: blur(10px);
|
||||||
|
filter: drop-shadow(8px 8px 10px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window_topbar {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 51;
|
||||||
|
background-color: @topbar_background;
|
||||||
|
|
||||||
|
height: @topbar_height;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin: 0 5px;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-left: 20px;
|
||||||
|
|
||||||
|
color: #fff - @topbar_background;
|
||||||
|
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: "JetBrains Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
|
||||||
|
color: #fff - @topbar_background;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
height: fit-content;
|
||||||
|
width: fit-content;
|
||||||
|
line-height: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
> div:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.window_body {
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
|
padding: 10px 20px;
|
||||||
|
height: calc(100% - @topbar_height);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
overflow: overlay;
|
||||||
|
user-select: text !important;
|
||||||
|
--webkit-user-select: text !important;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
user-select: text !important;
|
||||||
|
--webkit-user-select: text !important;
|
||||||
|
}
|
||||||
|
}
|
85
packages/app/src/components/ScheduledProgress/index.jsx
Normal file
85
packages/app/src/components/ScheduledProgress/index.jsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react"
|
||||||
|
import moment from "moment"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const defaultDateFormat = "DD-MM-YYYY hh:mm"
|
||||||
|
|
||||||
|
export default class ScheduledProgress extends React.Component {
|
||||||
|
isDateReached = (date) => {
|
||||||
|
const format = this.props.dateFormat ?? defaultDateFormat
|
||||||
|
const now = moment().format(format)
|
||||||
|
const result = moment(date, format).isSameOrBefore(moment(now, format))
|
||||||
|
|
||||||
|
console.debug(`[${date}] is before [${now}] => ${result}`)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
getDiffBetweenDates = (start, end) => {
|
||||||
|
// THIS IS NOT COUNTING WITH THE YEAR
|
||||||
|
const format = "DD-MM-YYYY"
|
||||||
|
|
||||||
|
const startDate = moment(start, format)
|
||||||
|
const endDate = moment(end, format)
|
||||||
|
const now = moment().format(format)
|
||||||
|
|
||||||
|
// count days will took to complete
|
||||||
|
const days = endDate.diff(startDate, "days")
|
||||||
|
|
||||||
|
const daysLeft = endDate.diff(moment(now, format), "days")
|
||||||
|
const daysPassed = moment(now, format).diff(startDate, "days")
|
||||||
|
|
||||||
|
let percentage = 0
|
||||||
|
|
||||||
|
switch (daysLeft) {
|
||||||
|
case 0: {
|
||||||
|
percentage = 99
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
percentage = 95
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
if (daysPassed > 0 && daysPassed < days) {
|
||||||
|
percentage = (daysPassed / days) * 100
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysPassed > days) {
|
||||||
|
percentage = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return { daysLeft, daysPassed, percentage }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const startReached = this.isDateReached(this.props.start)
|
||||||
|
const finishReached = this.isDateReached(this.props.finish)
|
||||||
|
const datesDiff = this.getDiffBetweenDates(this.props.start, this.props.finish)
|
||||||
|
|
||||||
|
return <div className="scheduled_progress">
|
||||||
|
<div className={classnames("scheduled_progress point", "scheduled_progress point left", { ["reached"]: startReached })}>
|
||||||
|
{this.props.start}
|
||||||
|
</div>
|
||||||
|
<antd.Progress
|
||||||
|
size="small"
|
||||||
|
percent={datesDiff.percentage}
|
||||||
|
showInfo={false}
|
||||||
|
className={classnames("ant-progress", {
|
||||||
|
startReached: startReached,
|
||||||
|
finishReached: finishReached,
|
||||||
|
})}
|
||||||
|
type="line"
|
||||||
|
/>
|
||||||
|
<div className={classnames("point", "right", { reached: finishReached })}>
|
||||||
|
{this.props.finish}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
68
packages/app/src/components/ScheduledProgress/index.less
Normal file
68
packages/app/src/components/ScheduledProgress/index.less
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
.scheduled_progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.point {
|
||||||
|
height: 40px;
|
||||||
|
text-align: center;
|
||||||
|
align-items: end;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
width: 72px;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.right {
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
transform: translate(50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.reached {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-progress::before {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 24px;
|
||||||
|
transform: translateX(-10px);
|
||||||
|
background-color: rgb(128, 128, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-progress::after {
|
||||||
|
content: "";
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 24px;
|
||||||
|
transform: translateX(10px);
|
||||||
|
background-color: rgb(128, 128, 128);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-progress {
|
||||||
|
font-size: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
&.startReached {
|
||||||
|
&::before {
|
||||||
|
background-color: var(--primaryColor) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.finishReached {
|
||||||
|
&::after {
|
||||||
|
background-color: var(--primaryColor) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-progress-bg {
|
||||||
|
background-color: var(--primaryColor);
|
||||||
|
}
|
||||||
|
}
|
32
packages/app/src/components/SearchButton/index.jsx
Normal file
32
packages/app/src/components/SearchButton/index.jsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { SearchBar } from "antd-mobile"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const searchBoxRef = React.useRef(null)
|
||||||
|
const [open, setOpen] = React.useState()
|
||||||
|
|
||||||
|
const openSearchBox = (to) => {
|
||||||
|
to = to ?? !open
|
||||||
|
setOpen(to)
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
searchBoxRef.current?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div
|
||||||
|
onClick={() => openSearchBox(true)}
|
||||||
|
className="searchButton">
|
||||||
|
<SearchBar
|
||||||
|
ref={searchBoxRef}
|
||||||
|
className={classnames("searchBox", { ["open"]: open })}
|
||||||
|
onSearch={props.onSearch}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onFocus={() => openSearchBox(true)}
|
||||||
|
onBlur={() => openSearchBox(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
18
packages/app/src/components/SearchButton/index.less
Normal file
18
packages/app/src/components/SearchButton/index.less
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
.searchButton {
|
||||||
|
.searchBox {
|
||||||
|
.adm-search-bar-input {
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
.adm-search-bar-input {
|
||||||
|
width: 20vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
360
packages/app/src/components/SelectableList/index.jsx
Normal file
360
packages/app/src/components/SelectableList/index.jsx
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Button } from "antd"
|
||||||
|
import classnames from "classnames"
|
||||||
|
import _ from "lodash"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
|
||||||
|
import { Icons, createIconRender } from "components/Icons"
|
||||||
|
import { ActionsBar, } from "components"
|
||||||
|
import { useLongPress, Haptics } from "utils"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ListItem = React.memo((props) => {
|
||||||
|
let { item } = props
|
||||||
|
|
||||||
|
if (!item.key) {
|
||||||
|
item.key = item._id ?? item.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleClickSpeed = 400
|
||||||
|
let delayedClick = null
|
||||||
|
let clickedOnce = null
|
||||||
|
|
||||||
|
const handleOnceClick = () => {
|
||||||
|
clickedOnce = null
|
||||||
|
|
||||||
|
if (typeof props.onClickItem === "function") {
|
||||||
|
return props.onClickItem(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDoubleClick = () => {
|
||||||
|
if (typeof props.onDoubleClickItem === "function") {
|
||||||
|
return props.onDoubleClickItem(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLongPress = () => {
|
||||||
|
if (typeof props.onLongPressItem === "function") {
|
||||||
|
return props.onLongPressItem(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderChildren = props.renderChildren(item)
|
||||||
|
const isDisabled = renderChildren.props.disabled
|
||||||
|
|
||||||
|
return React.createElement("div", {
|
||||||
|
id: item.key,
|
||||||
|
key: item.key,
|
||||||
|
disabled: isDisabled,
|
||||||
|
className: classnames("selectableList_item", {
|
||||||
|
["selected"]: props.selected,
|
||||||
|
["disabled"]: isDisabled,
|
||||||
|
}),
|
||||||
|
onDoubleClick: () => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDoubleClick()
|
||||||
|
},
|
||||||
|
...useLongPress(
|
||||||
|
// onLongPress
|
||||||
|
() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onlyClickSelection) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLongPress()
|
||||||
|
},
|
||||||
|
// onClick
|
||||||
|
() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.onlyClickSelection) {
|
||||||
|
return handleOnceClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!delayedClick) {
|
||||||
|
delayedClick = _.debounce(handleOnceClick, doubleClickSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clickedOnce) {
|
||||||
|
delayedClick.cancel()
|
||||||
|
clickedOnce = false
|
||||||
|
handleDoubleClick()
|
||||||
|
} else {
|
||||||
|
clickedOnce = true
|
||||||
|
delayedClick()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shouldPreventDefault: true,
|
||||||
|
delay: props.longPressDelay ?? 300,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}, renderChildren)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default class SelectableList extends React.Component {
|
||||||
|
state = {
|
||||||
|
selectedKeys: [],
|
||||||
|
selectionEnabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (typeof this.props.defaultSelected !== "undefined" && Array.isArray(this.props.defaultSelected)) {
|
||||||
|
this.setState({
|
||||||
|
selectedKeys: [...this.props.defaultSelected],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
if (prevState.selectionEnabled !== this.state.selectionEnabled) {
|
||||||
|
if (this.state.selectionEnabled) {
|
||||||
|
this.handleFeedbackEvent("selectionStart")
|
||||||
|
} else {
|
||||||
|
this.handleFeedbackEvent("selectionEnd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFeedbackEvent = (event) => {
|
||||||
|
if (typeof Haptics[event] === "function") {
|
||||||
|
return Haptics[event]()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isKeySelected = (key) => {
|
||||||
|
return this.state.selectedKeys.includes(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
isAllSelected = () => {
|
||||||
|
return this.state.selectedKeys.length === this.props.items.length
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAll = () => {
|
||||||
|
if (this.props.items.length > 0) {
|
||||||
|
let updatedSelectedKeys = [...this.props.items.map((item) => item.key ?? item.id ?? item._id)]
|
||||||
|
|
||||||
|
if (typeof this.props.disabledKeys !== "undefined") {
|
||||||
|
updatedSelectedKeys = updatedSelectedKeys.filter((key) => {
|
||||||
|
return !this.props.disabledKeys.includes(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleFeedbackEvent("selectionChanged")
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
selectionEnabled: true,
|
||||||
|
selectedKeys: updatedSelectedKeys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unselectAll = () => {
|
||||||
|
this.setState({
|
||||||
|
selectionEnabled: false,
|
||||||
|
selectedKeys: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
selectKey = (key) => {
|
||||||
|
let list = this.state.selectedKeys ?? []
|
||||||
|
list.push(key)
|
||||||
|
|
||||||
|
this.handleFeedbackEvent("selectionChanged")
|
||||||
|
|
||||||
|
return this.setState({ selectedKeys: list })
|
||||||
|
}
|
||||||
|
|
||||||
|
unselectKey = (key) => {
|
||||||
|
let list = this.state.selectedKeys ?? []
|
||||||
|
list = list.filter((_key) => key !== _key)
|
||||||
|
|
||||||
|
this.handleFeedbackEvent("selectionChanged")
|
||||||
|
|
||||||
|
return this.setState({ selectedKeys: list })
|
||||||
|
}
|
||||||
|
|
||||||
|
onDone = () => {
|
||||||
|
if (typeof this.props.onDone === "function") {
|
||||||
|
this.props.onDone(this.state.selectedKeys)
|
||||||
|
}
|
||||||
|
this.unselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDiscard = () => {
|
||||||
|
if (typeof this.props.onDiscard === "function") {
|
||||||
|
this.props.onDiscard(this.state.selectedKeys)
|
||||||
|
}
|
||||||
|
this.unselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDoubleClickItem = (key) => {
|
||||||
|
if (typeof this.props.onDoubleClick === "function") {
|
||||||
|
this.props.onDoubleClick(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickItem = (key) => {
|
||||||
|
if (this.props.overrideSelectionEnabled || this.state.selectionEnabled) {
|
||||||
|
if (this.isKeySelected(key)) {
|
||||||
|
this.unselectKey(key)
|
||||||
|
} else {
|
||||||
|
this.selectKey(key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof this.props.onClickItem === "function") {
|
||||||
|
this.props.onClickItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLongPressItem = (key) => {
|
||||||
|
if (this.props.overrideSelectionEnabled) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.selectionEnabled) {
|
||||||
|
this.selectKey(key)
|
||||||
|
this.setState({ selectionEnabled: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProvidedActions = () => {
|
||||||
|
return this.props.actions.map((action) => {
|
||||||
|
return (
|
||||||
|
<div key={action.key}>
|
||||||
|
<Button
|
||||||
|
type={action.props.type}
|
||||||
|
shape={action.props.shape}
|
||||||
|
size={action.props.size}
|
||||||
|
style={{
|
||||||
|
...action.props.style,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (typeof this.props.events === "undefined") {
|
||||||
|
console.error("No events provided to SelectableList")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof action.onClick === "function") {
|
||||||
|
action.onClick(this.state.selectedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.props.events[action.props.call] === "function") {
|
||||||
|
this.props.events[action.props.call]({
|
||||||
|
onDone: this.onDone,
|
||||||
|
onDiscard: this.onDiscard,
|
||||||
|
onCancel: this.onCancel,
|
||||||
|
selectKey: this.selectKey,
|
||||||
|
unselectKey: this.unselectKey,
|
||||||
|
selectAll: this.selectAll,
|
||||||
|
unselectAll: this.unselectAll,
|
||||||
|
isKeySelected: this.isKeySelected,
|
||||||
|
isAllSelected: this.isAllSelected,
|
||||||
|
}, this.state.selectedKeys)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getLongPressDelay = () => {
|
||||||
|
return window.app.settings.get("selection_longPress_timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItems = (data) => {
|
||||||
|
return data.length > 0 ? data.map((item, index) => {
|
||||||
|
item.key = item.key ?? item.id ?? item._id
|
||||||
|
|
||||||
|
if (item.children && Array.isArray(item.children)) {
|
||||||
|
return <div className="selectableList_group">
|
||||||
|
<h1>
|
||||||
|
{React.isValidElement(item.icon) ? item.icon : Icons[item.icon] && createIconRender(item.icon)}
|
||||||
|
<Translation>
|
||||||
|
{t => t(item.label)}
|
||||||
|
</Translation>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="selectableList_subItems">
|
||||||
|
{this.renderItems(item.children)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = this.isKeySelected(item.key)
|
||||||
|
|
||||||
|
return <ListItem
|
||||||
|
item={item}
|
||||||
|
selected={selected}
|
||||||
|
longPressDelay={this.getLongPressDelay()}
|
||||||
|
|
||||||
|
onClickItem={this.onClickItem}
|
||||||
|
onDoubleClickItem={this.onDoubleClickItem}
|
||||||
|
onLongPressItem={this.onLongPressItem}
|
||||||
|
|
||||||
|
renderChildren={this.props.renderItem}
|
||||||
|
onlyClickSelection={this.props.onlyClickSelection || this.state.selectionEnabled}
|
||||||
|
/>
|
||||||
|
}) : <antd.Empty image={antd.Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.props.overrideSelectionEnabled && this.state.selectionEnabled && this.state.selectedKeys.length === 0) {
|
||||||
|
this.setState({ selectionEnabled: false })
|
||||||
|
this.unselectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllSelected = this.isAllSelected()
|
||||||
|
let items = this.renderItems(this.props.items)
|
||||||
|
|
||||||
|
return <div className={classnames("selectableList", { ["selectionEnabled"]: this.props.overrideSelectionEnabled ?? this.state.selectionEnabled })}>
|
||||||
|
<div className="selectableList_content">
|
||||||
|
{items}
|
||||||
|
</div>
|
||||||
|
{this.props.items.length > 0 && (this.props.overrideSelectionEnabled || this.state.selectionEnabled) && !this.props.actionsDisabled &&
|
||||||
|
<ActionsBar mode="float">
|
||||||
|
<div key="discard">
|
||||||
|
<Button
|
||||||
|
shape="round"
|
||||||
|
onClick={this.onDiscard}
|
||||||
|
{...this.props.onDiscardProps}
|
||||||
|
>
|
||||||
|
{this.props.onDiscardRender ?? <Icons.X />}
|
||||||
|
<Translation>
|
||||||
|
{(t) => t("Discard")}
|
||||||
|
</Translation>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{this.props.bulkSelectionAction &&
|
||||||
|
<div key="allSelection">
|
||||||
|
<Button
|
||||||
|
shape="round"
|
||||||
|
onClick={() => isAllSelected ? this.unselectAll() : this.selectAll()}
|
||||||
|
>
|
||||||
|
<Translation>
|
||||||
|
{(t) => t(isAllSelected ? "Unselect all" : "Select all")}
|
||||||
|
</Translation>
|
||||||
|
</Button>
|
||||||
|
</div>}
|
||||||
|
{Array.isArray(this.props.actions) && this.renderProvidedActions()}
|
||||||
|
</ActionsBar>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
82
packages/app/src/components/SelectableList/index.less
Normal file
82
packages/app/src/components/SelectableList/index.less
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
@selectableList_item_borderColor_active: rgba(51, 51, 51, 1);
|
||||||
|
@selectableList_item_borderColor_normal: rgba(51, 51, 51, 0.3);
|
||||||
|
|
||||||
|
.selectableList {
|
||||||
|
.selectableList_content {
|
||||||
|
.selectableList_item {
|
||||||
|
--ignore-dragger: true;
|
||||||
|
display: inline-flex;
|
||||||
|
overflow-x: overlay;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
user-select: none;
|
||||||
|
--webkit-user-select: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
border: @selectableList_item_borderColor_normal 1px solid;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 7px;
|
||||||
|
|
||||||
|
transition: all 150ms ease-in-out;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
transform: scale(0.98);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectableList_item:active {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
transform: scale(0.98);
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selectionEnabled {
|
||||||
|
.selectableList_content {
|
||||||
|
.selectableList_item {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
border: rgba(51, 51, 51, 0.3) 1px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
h1, h3 {
|
||||||
|
user-select: none;
|
||||||
|
--webkit-user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectableList_group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.selectableList_subItems {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
31
packages/app/src/components/ServerStatus/index.jsx
Normal file
31
packages/app/src/components/ServerStatus/index.jsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const [connected, setConnected] = React.useState(window.app.ws.mainSocketConnected ?? false)
|
||||||
|
|
||||||
|
window.app.eventBus.on("websocket_connected", (status) => {
|
||||||
|
setConnected(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.app.eventBus.on("websocket_disconnected", (status) => {
|
||||||
|
setConnected(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getColor = () => {
|
||||||
|
if (!connected) {
|
||||||
|
return "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "blue"
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>
|
||||||
|
<div key="health">
|
||||||
|
<Icons.Activity /> <antd.Tag color={getColor()}>
|
||||||
|
{connected ? "Connected" : "Disconnected"}
|
||||||
|
</antd.Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
402
packages/app/src/components/Settings/index.jsx
Normal file
402
packages/app/src/components/Settings/index.jsx
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { SliderPicker } from "react-color"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
import classnames from "classnames"
|
||||||
|
|
||||||
|
import config from "config"
|
||||||
|
import { Icons, createIconRender } from "components/Icons"
|
||||||
|
|
||||||
|
import AppSettings from "schemas/settings/app"
|
||||||
|
import AccountSettings from "schemas/settings/account"
|
||||||
|
|
||||||
|
import groupsDecorator from "schemas/settingsGroupsDecorator.json"
|
||||||
|
|
||||||
|
import { AboutApp } from ".."
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const ItemTypes = {
|
||||||
|
Button: antd.Button,
|
||||||
|
Switch: antd.Switch,
|
||||||
|
Slider: antd.Slider,
|
||||||
|
Checkbox: antd.Checkbox,
|
||||||
|
Input: antd.Input,
|
||||||
|
InputNumber: antd.InputNumber,
|
||||||
|
Select: antd.Select,
|
||||||
|
SliderColorPicker: SliderPicker,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingItem = (props) => {
|
||||||
|
let { item } = props
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [value, setValue] = React.useState(item.defaultValue ?? false)
|
||||||
|
const [delayedValue, setDelayedValue] = React.useState(null)
|
||||||
|
|
||||||
|
if (!item.type) {
|
||||||
|
console.error(`Item [${item.id}] has no an type!`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof ItemTypes[item.type] === "undefined") {
|
||||||
|
console.error(`Item [${item.id}] has an invalid type: ${item.type}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.props === "undefined") {
|
||||||
|
item.props = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchUpdate = async (updateValue) => {
|
||||||
|
if (typeof item.onUpdate === "function") {
|
||||||
|
const result = await item.onUpdate(updateValue).catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
antd.message.error(error.message)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
updateValue = result
|
||||||
|
} else {
|
||||||
|
const storagedValue = await window.app.settings.get(item.id)
|
||||||
|
|
||||||
|
if (typeof updateValue === "undefined") {
|
||||||
|
updateValue = !storagedValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.emitEvent === "string") {
|
||||||
|
let emissionPayload = updateValue
|
||||||
|
|
||||||
|
if (typeof item.emissionValueUpdate === "function") {
|
||||||
|
emissionPayload = item.emissionValueUpdate(emissionPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.app.eventBus.emit(item.emitEvent, emissionPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.noUpdate) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.storaged) {
|
||||||
|
await window.app.settings.set(item.id, updateValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.debounced) {
|
||||||
|
setDelayedValue(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(updateValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUpdateItem = async (updateValue) => {
|
||||||
|
setValue(updateValue)
|
||||||
|
|
||||||
|
if (!item.debounced) {
|
||||||
|
await dispatchUpdate(updateValue)
|
||||||
|
} else {
|
||||||
|
setDelayedValue(updateValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingInitialization = async () => {
|
||||||
|
if (item.storaged) {
|
||||||
|
const storagedValue = window.app.settings.get(item.id)
|
||||||
|
setValue(storagedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.defaultValue === "function") {
|
||||||
|
setValue(await item.defaultValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.dependsOn === "object") {
|
||||||
|
const dependsOptionsKeys = Object.keys(item.dependsOn)
|
||||||
|
|
||||||
|
item.props.disabled = !Boolean(dependsOptionsKeys.every((key) => {
|
||||||
|
const storagedValue = window.app.settings.get(key)
|
||||||
|
|
||||||
|
if (typeof item.dependsOn[key] === "function") {
|
||||||
|
return item.dependsOn[key](storagedValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return storagedValue === item.dependsOn[key]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item.listenUpdateValue === "string") {
|
||||||
|
window.app.eventBus.on(`setting.update.${item.listenUpdateValue}`, (value) => setValue(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.reloadValueOnUpdateEvent) {
|
||||||
|
window.app.eventBus.on(item.reloadValueOnUpdateEvent, () => {
|
||||||
|
console.log(`Reloading value for item [${item.id}]`)
|
||||||
|
settingInitialization()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
settingInitialization()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
switch (item.type.toLowerCase()) {
|
||||||
|
case "slidercolorpicker": {
|
||||||
|
item.props.onChange = (color) => {
|
||||||
|
item.props.color = color.hex
|
||||||
|
}
|
||||||
|
item.props.onChangeComplete = (color) => {
|
||||||
|
onUpdateItem(color.hex)
|
||||||
|
}
|
||||||
|
|
||||||
|
item.props.color = value
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "input": {
|
||||||
|
item.props.defaultValue = value
|
||||||
|
item.props.onPressEnter = (event) => dispatchUpdate(event.target.value)
|
||||||
|
item.props.onChange = (event) => onUpdateItem(event.target.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "switch": {
|
||||||
|
item.props.checked = value
|
||||||
|
item.props.onClick = (event) => onUpdateItem(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "select": {
|
||||||
|
item.props.onChange = (value) => onUpdateItem(value)
|
||||||
|
item.props.defaultValue = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "slider": {
|
||||||
|
item.props.defaultValue = value
|
||||||
|
item.props.onAfterChange = (value) => onUpdateItem(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
if (!item.props.children) {
|
||||||
|
item.props.children = item.title ?? item.id
|
||||||
|
}
|
||||||
|
item.props.value = item.defaultValue
|
||||||
|
item.props.onClick = (event) => onUpdateItem(event)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div key={item.id} className="settingItem">
|
||||||
|
<div className="header">
|
||||||
|
<div className="title">
|
||||||
|
<div>
|
||||||
|
<h4>
|
||||||
|
{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null}
|
||||||
|
<Translation>{
|
||||||
|
t => t(item.title ?? item.id)
|
||||||
|
}</Translation>
|
||||||
|
</h4>
|
||||||
|
<p> <Translation>{
|
||||||
|
t => t(item.description)
|
||||||
|
}</Translation></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{item.experimental && <antd.Tag> Experimental </antd.Tag>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{item.extraActions &&
|
||||||
|
<div className="extraActions">
|
||||||
|
{item.extraActions.map((action, index) => {
|
||||||
|
return <div>
|
||||||
|
<antd.Button
|
||||||
|
key={action.id}
|
||||||
|
id={action.id}
|
||||||
|
onClick={action.onClick}
|
||||||
|
icon={action.icon && createIconRender(action.icon)}
|
||||||
|
type={action.type ?? "round"}
|
||||||
|
>
|
||||||
|
{action.title}
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className="component">
|
||||||
|
<div>
|
||||||
|
{loading ? <div> Loading... </div> : React.createElement(ItemTypes[item.type], item.props)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{delayedValue && <div>
|
||||||
|
<antd.Button
|
||||||
|
type="round"
|
||||||
|
icon={<Icons.Save />}
|
||||||
|
onClick={async () => await dispatchUpdate(value)}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</antd.Button>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class SettingsMenu extends React.PureComponent {
|
||||||
|
state = {
|
||||||
|
transitionActive: false,
|
||||||
|
activeKey: "app"
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (typeof this.props.close === "function") {
|
||||||
|
// register escape key to close settings menu
|
||||||
|
window.addEventListener("keydown", this.handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if (typeof this.props.close === "function") {
|
||||||
|
window.removeEventListener("keydown", this.handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown = (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
this.props.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePageTransition = (key) => {
|
||||||
|
this.setState({
|
||||||
|
transitionActive: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({
|
||||||
|
activeKey: key
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({
|
||||||
|
transitionActive: false,
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSettings = (key, group) => {
|
||||||
|
const fromDecoratorIcon = groupsDecorator[key]?.icon
|
||||||
|
const fromDecoratorTitle = groupsDecorator[key]?.title
|
||||||
|
|
||||||
|
return <div className={classnames("fade-opacity-active", { "fade-opacity-leave": this.state.transitionActive })}>
|
||||||
|
<div key={key} className="group">
|
||||||
|
<h1>
|
||||||
|
{fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null}
|
||||||
|
<Translation>{
|
||||||
|
t => t(fromDecoratorTitle ?? key)
|
||||||
|
}</Translation>
|
||||||
|
</h1>
|
||||||
|
<div className="content">
|
||||||
|
{group.map((item) => <SettingItem item={item} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
generateSettingsGroups = (data) => {
|
||||||
|
let groups = {}
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
if (!groups[item.group]) {
|
||||||
|
groups[item.group] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
groups[item.group].push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.keys(groups).map((groupKey) => {
|
||||||
|
return this.renderSettings(groupKey, groups[groupKey])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const isDevMode = window.__evite?.env?.NODE_ENV !== "production"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings">
|
||||||
|
<antd.Tabs
|
||||||
|
activeKey={this.state.activeKey}
|
||||||
|
centered
|
||||||
|
destroyInactiveTabPane
|
||||||
|
onTabClick={this.handlePageTransition}
|
||||||
|
>
|
||||||
|
<antd.Tabs.TabPane
|
||||||
|
key="app"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<Icons.Command />
|
||||||
|
App
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{this.generateSettingsGroups(AppSettings)}
|
||||||
|
<div className="footer">
|
||||||
|
<div>
|
||||||
|
<div>{config.app?.siteName}</div>
|
||||||
|
<div>
|
||||||
|
<antd.Tag>
|
||||||
|
<Icons.Tag />v{window.app.version}
|
||||||
|
</antd.Tag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<antd.Tag color={isDevMode ? "magenta" : "green"}>
|
||||||
|
{isDevMode ? <Icons.Triangle /> : <Icons.Box />}
|
||||||
|
{isDevMode ? "development" : "stable"}
|
||||||
|
</antd.Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<antd.Button type="link" onClick={() => AboutApp.openModal()}>
|
||||||
|
<Translation>
|
||||||
|
{t => t("about")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</antd.Tabs.TabPane>
|
||||||
|
<antd.Tabs.TabPane
|
||||||
|
key="account"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<Icons.User />
|
||||||
|
Account
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{this.generateSettingsGroups(AccountSettings)}
|
||||||
|
</antd.Tabs.TabPane>
|
||||||
|
<antd.Tabs.TabPane
|
||||||
|
key="security"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<Icons.Shield />
|
||||||
|
Security
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
</antd.Tabs.TabPane>
|
||||||
|
<antd.Tabs.TabPane
|
||||||
|
key="privacy"
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<Icons.Eye />
|
||||||
|
Privacy
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
</antd.Tabs.TabPane>
|
||||||
|
</antd.Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
138
packages/app/src/components/Settings/index.less
Normal file
138
packages/app/src/components/Settings/index.less
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
.settings {
|
||||||
|
display : flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
display : flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
>div {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settingItem {
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display : inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items : center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display : flex;
|
||||||
|
align-items: center;
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 11px;
|
||||||
|
color : var(--background-color-contrast);
|
||||||
|
margin : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.extraActions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.component {
|
||||||
|
display : flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
--ignore-dragger: true;
|
||||||
|
padding : 0 20px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
width : 100%;
|
||||||
|
|
||||||
|
padding-top : 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
|
||||||
|
display : flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
justify-content: center;
|
||||||
|
align-items : center;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
font-family: "Space Mono", monospace;
|
||||||
|
font-size : 10px;
|
||||||
|
|
||||||
|
display : flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items : center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.ant-tag {
|
||||||
|
height : 18px;
|
||||||
|
line-height: 18px;
|
||||||
|
font-size : 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
padding: 0 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-tabs-nav-list {
|
||||||
|
width : 100%;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
}
|
15
packages/app/src/components/Skeleton/index.jsx
Normal file
15
packages/app/src/components/Skeleton/index.jsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Skeleton } from "antd"
|
||||||
|
import { LoadingOutlined } from "@ant-design/icons"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <div className="skeleton">
|
||||||
|
<div className="indicator">
|
||||||
|
<LoadingOutlined spin />
|
||||||
|
<h3>Loading...</h3>
|
||||||
|
</div>
|
||||||
|
<Skeleton active />
|
||||||
|
</div>
|
||||||
|
}
|
22
packages/app/src/components/Skeleton/index.less
Normal file
22
packages/app/src/components/Skeleton/index.less
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
.skeleton {
|
||||||
|
svg {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
background-color: var(--background-color-accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
244
packages/app/src/components/StepsForm/index.jsx
Normal file
244
packages/app/src/components/StepsForm/index.jsx
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import loadable from "@loadable/component"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
|
||||||
|
import { Icons, createIconRender } from "components/Icons"
|
||||||
|
import { ActionsBar } from "components"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default class StepsForm extends React.Component {
|
||||||
|
state = {
|
||||||
|
steps: [...(this.props.steps ?? []), ...(this.props.children ?? [])],
|
||||||
|
|
||||||
|
step: 0,
|
||||||
|
values: {},
|
||||||
|
canNext: true,
|
||||||
|
renderStep: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
if (this.props.defaultValues) {
|
||||||
|
await this.setState({ values: this.props.defaultValues })
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.handleNext(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
next = (to) => {
|
||||||
|
if (!this.state.canNext) {
|
||||||
|
return antd.message.error("Please complete the step.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.handleNext(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
prev = () => this.handlePrev()
|
||||||
|
|
||||||
|
handleNext = (to) => {
|
||||||
|
const index = to ?? (this.state.step + 1)
|
||||||
|
|
||||||
|
this.setState({ step: index, renderStep: this.renderStep(index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePrev = () => {
|
||||||
|
this.handleNext(this.state.step - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleError = (error) => {
|
||||||
|
this.setState({ submitting: false, submittingError: error })
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdate = (key, value) => {
|
||||||
|
this.setState({ values: { ...this.state.values, [key]: value } }, () => {
|
||||||
|
if (typeof this.props.onChange === "function") {
|
||||||
|
this.props.onChange(this.state.values)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleValidation = (result) => {
|
||||||
|
this.setState({ canNext: result })
|
||||||
|
}
|
||||||
|
|
||||||
|
canSubmit = () => {
|
||||||
|
if (typeof this.props.canSubmit === "function") {
|
||||||
|
return this.props.canSubmit(this.state.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit = async () => {
|
||||||
|
if (!this.state.canNext) {
|
||||||
|
console.warn("Cannot submit form, validation failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof this.props.onSubmit === "function") {
|
||||||
|
this.setState({ submitting: true, submittingError: null })
|
||||||
|
|
||||||
|
await this.props.onSubmit(this.state.values).catch((error) => {
|
||||||
|
console.error(error)
|
||||||
|
this.handleError(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStep = (stepIndex) => {
|
||||||
|
const step = this.state.steps[stepIndex]
|
||||||
|
|
||||||
|
let content = step.content
|
||||||
|
let value = this.state.values[step.key]
|
||||||
|
|
||||||
|
if (typeof step.key === "undefined") {
|
||||||
|
console.error("[StepsForm] step.key is required")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof step.required !== "undefined" && step.required) {
|
||||||
|
this.handleValidation(Boolean(value && value.length > 0))
|
||||||
|
} else {
|
||||||
|
this.setState({ canNext: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof step.stateValidation === "function") {
|
||||||
|
const validationResult = step.stateValidation(value)
|
||||||
|
this.handleValidation(validationResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentProps = {
|
||||||
|
handleUpdate: (to) => {
|
||||||
|
value = to
|
||||||
|
|
||||||
|
if (typeof step.onUpdateValue === "function") {
|
||||||
|
value = step.onUpdateValue(value, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
let validationResult = true
|
||||||
|
|
||||||
|
if (typeof step.stateValidation === "function") {
|
||||||
|
validationResult = step.stateValidation(to)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof step.required !== "undefined" && step.required) {
|
||||||
|
validationResult = Boolean(to && to.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleUpdate(step.key, to)
|
||||||
|
this.handleValidation(validationResult)
|
||||||
|
},
|
||||||
|
handleError: (error) => {
|
||||||
|
if (typeof props.handleError === "function") {
|
||||||
|
this.handleError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPressEnter: () => this.next(),
|
||||||
|
value: value,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof step.content === "function") {
|
||||||
|
content = loadable(async () => {
|
||||||
|
try {
|
||||||
|
const component = React.createElement(step.content, componentProps)
|
||||||
|
return () => component
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
|
||||||
|
antd.notification.error({
|
||||||
|
message: "Error",
|
||||||
|
description: "Error loading step content",
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => <div>
|
||||||
|
<Icons.XCircle /> Error
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
fallback: <div>Loading...</div>,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.createElement(React.memo(content), componentProps)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.steps.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = this.state.steps
|
||||||
|
const current = steps[this.state.step]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="steps_form">
|
||||||
|
<div className="steps_form steps">
|
||||||
|
<antd.Steps responsive={false} direction="horizontal" className="steps_form steps header" size="small" current={this.state.step}>
|
||||||
|
{steps.map(item => (
|
||||||
|
<antd.Steps.Step key={item.title} />
|
||||||
|
))}
|
||||||
|
</antd.Steps>
|
||||||
|
|
||||||
|
<div className="steps_form steps step">
|
||||||
|
<div className="title">
|
||||||
|
<h1>{current.icon && createIconRender(current.icon)}
|
||||||
|
<Translation>
|
||||||
|
{t => t(current.title)}
|
||||||
|
</Translation>
|
||||||
|
</h1>
|
||||||
|
<antd.Tag color={current.required ? "volcano" : "default"}>
|
||||||
|
<Translation>
|
||||||
|
{t => t(current.required ? "Required" : "Optional")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Tag>
|
||||||
|
</div>
|
||||||
|
{current.description && <div className="description">
|
||||||
|
<Translation>
|
||||||
|
{t => t(current.description)}
|
||||||
|
</Translation>
|
||||||
|
</div>}
|
||||||
|
{this.state.renderStep}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{this.state.submittingError && (
|
||||||
|
<div style={{ color: "#f5222d" }}>
|
||||||
|
<Translation>
|
||||||
|
{t => t(String(this.state.submittingError))}
|
||||||
|
</Translation>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActionsBar mode="float">
|
||||||
|
{this.state.step > 0 && (
|
||||||
|
<antd.Button style={{ margin: "0 8px" }} onClick={() => this.prev()}>
|
||||||
|
<Icons.ChevronLeft />
|
||||||
|
<Translation>
|
||||||
|
{t => t("Previous")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Button>
|
||||||
|
)}
|
||||||
|
{this.state.step < steps.length - 1 && (
|
||||||
|
<antd.Button disabled={!this.state.canNext} type="primary" onClick={() => this.next()}>
|
||||||
|
<Icons.ChevronRight />
|
||||||
|
<Translation>
|
||||||
|
{t => t("Next")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Button>
|
||||||
|
)}
|
||||||
|
{this.state.step === steps.length - 1 && (
|
||||||
|
<antd.Button disabled={!this.state.canNext || this.state.submitting || !this.canSubmit()} type="primary" onClick={this.onSubmit}>
|
||||||
|
{this.state.submitting && <Icons.LoadingOutlined spin />}
|
||||||
|
<Translation>
|
||||||
|
{t => t("Done")}
|
||||||
|
</Translation>
|
||||||
|
</antd.Button>
|
||||||
|
)}
|
||||||
|
</ActionsBar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
91
packages/app/src/components/StepsForm/index.less
Normal file
91
packages/app/src/components/StepsForm/index.less
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
.steps_form {
|
||||||
|
.ant-steps-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
//position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
padding: 0 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
color: var(--background-color-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.ant-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
176
packages/app/src/components/SwipeItem/index.jsx
Normal file
176
packages/app/src/components/SwipeItem/index.jsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import React from "react"
|
||||||
|
import PropTypes from "prop-types"
|
||||||
|
import { cursorPosition } from "utils"
|
||||||
|
import { Container, Delete, Content } from "./styles"
|
||||||
|
|
||||||
|
class SwipeToDelete extends React.Component {
|
||||||
|
state = {
|
||||||
|
touching: null,
|
||||||
|
translate: 0,
|
||||||
|
deleting: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
// to get ref dimensions
|
||||||
|
this.forceUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseDown = (e) => {
|
||||||
|
if (this.props.disabled) return
|
||||||
|
if (this.state.touching) return
|
||||||
|
|
||||||
|
this.startTouchPosition = cursorPosition(e)
|
||||||
|
this.initTranslate = this.state.translate
|
||||||
|
|
||||||
|
this.setState({ touching: true }, () => {
|
||||||
|
this.addEventListenerToMoveAndUp()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListenerToMoveAndUp = (remove = false) => {
|
||||||
|
if (remove) {
|
||||||
|
window.removeEventListener("mousemove", this.onMouseMove)
|
||||||
|
window.removeEventListener("touchmove", this.onMouseMove)
|
||||||
|
window.removeEventListener("mouseup", this.onMouseUp)
|
||||||
|
window.removeEventListener("touchend", this.onMouseUp)
|
||||||
|
} else {
|
||||||
|
window.addEventListener("mousemove", this.onMouseMove)
|
||||||
|
window.addEventListener("touchmove", this.onMouseMove)
|
||||||
|
window.addEventListener("mouseup", this.onMouseUp)
|
||||||
|
window.addEventListener("touchend", this.onMouseUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove = (e) => {
|
||||||
|
const { rtl } = this.props
|
||||||
|
|
||||||
|
if (!this.state.touching) {
|
||||||
|
return cursorPosition(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(!rtl && cursorPosition(e) > this.startTouchPosition - this.initTranslate)
|
||||||
|
|| (rtl && cursorPosition(e) < this.startTouchPosition - this.initTranslate)
|
||||||
|
) {
|
||||||
|
this.setState({ translate: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ translate: cursorPosition(e) - this.startTouchPosition + this.initTranslate })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp = () => {
|
||||||
|
this.startTouchPosition = null
|
||||||
|
|
||||||
|
const { deleteWidth, rtl } = this.props
|
||||||
|
const newState = {
|
||||||
|
touching: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptableMove = -deleteWidth * 0.7
|
||||||
|
const showDelete = (rtl ? -1 : 1) * this.state.translate < acceptableMove
|
||||||
|
const notShowDelete = (rtl ? -1 : 1) * this.state.translate >= acceptableMove
|
||||||
|
const deleteWithoutConfirm = (rtl ? 1 : -1) * this.state.translate >= this.deleteWithoutConfirmThreshold
|
||||||
|
|
||||||
|
if (deleteWithoutConfirm) {
|
||||||
|
newState.translate = -this.containerWidth
|
||||||
|
}
|
||||||
|
if (notShowDelete) {
|
||||||
|
newState.translate = 0
|
||||||
|
}
|
||||||
|
if (showDelete && !deleteWithoutConfirm) {
|
||||||
|
newState.translate = (rtl ? 1 : -1) * deleteWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(newState, () => {
|
||||||
|
if (deleteWithoutConfirm) {
|
||||||
|
this.onDeleteClick()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.addEventListenerToMoveAndUp(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteClick = () => {
|
||||||
|
const { transitionDuration, onDelete } = this.props
|
||||||
|
|
||||||
|
this.setState({ deleting: true }, () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
onDelete()
|
||||||
|
}, transitionDuration)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.addEventListenerToMoveAndUp(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { translate, touching, deleting } = this.state
|
||||||
|
const { deleteWidth, transitionDuration, deleteText, deleteComponent, deleteColor, height, rtl } = this.props
|
||||||
|
|
||||||
|
const cssParams = { deleteWidth, transitionDuration, deleteColor, heightProp: height, rtl }
|
||||||
|
const shiftDelete = -translate >= this.deleteWithoutConfirmThreshold
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ignore-dragger
|
||||||
|
id="delete-container"
|
||||||
|
deleting={deleting}
|
||||||
|
{...cssParams}
|
||||||
|
ref={c => {
|
||||||
|
if (c) {
|
||||||
|
this.container = c
|
||||||
|
this.containerWidth = c.getBoundingClientRect().width
|
||||||
|
this.deleteWithoutConfirmThreshold = this.containerWidth * 0.75
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Delete
|
||||||
|
ignore-dragger
|
||||||
|
id="delete"
|
||||||
|
buttonMargin={shiftDelete ? this.containerWidth + translate : this.containerWidth - deleteWidth}
|
||||||
|
{...cssParams}
|
||||||
|
>
|
||||||
|
<button id="delete-button" onClick={this.onDeleteClick}>{deleteComponent ? deleteComponent : deleteText}</button>
|
||||||
|
</Delete>
|
||||||
|
<Content
|
||||||
|
{...cssParams}
|
||||||
|
ignore-dragger
|
||||||
|
id="delete-content"
|
||||||
|
deleting={deleting}
|
||||||
|
onMouseDown={this.onMouseDown}
|
||||||
|
onTouchStart={this.onMouseDown}
|
||||||
|
translate={translate}
|
||||||
|
transition={!touching}
|
||||||
|
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</Content>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDelete.propTypes = {
|
||||||
|
onDelete: PropTypes.func.isRequired,
|
||||||
|
height: PropTypes.number.isRequired,
|
||||||
|
transitionDuration: PropTypes.number,
|
||||||
|
deleteWidth: PropTypes.number,
|
||||||
|
deleteColor: PropTypes.string,
|
||||||
|
deleteText: PropTypes.string,
|
||||||
|
deleteComponent: PropTypes.node,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
rtl: PropTypes.bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
SwipeToDelete.defaultProps = {
|
||||||
|
transitionDuration: 250,
|
||||||
|
deleteWidth: 75,
|
||||||
|
deleteColor: "rgba(252, 58, 48, 1.00)",
|
||||||
|
deleteText: "Delete",
|
||||||
|
disabled: false,
|
||||||
|
rtl: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SwipeToDelete
|
55
packages/app/src/components/SwipeItem/styles.js
Normal file
55
packages/app/src/components/SwipeItem/styles.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import styled, { css } from "styled-components"
|
||||||
|
|
||||||
|
export const deletingCss = css`
|
||||||
|
transition: all ${({ transitionDuration }) => transitionDuration}ms ease-out;
|
||||||
|
max-height: 0;
|
||||||
|
* {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Container = styled.div`
|
||||||
|
height: ${({ heightProp }) => heightProp}px;
|
||||||
|
max-height: ${({ heightProp }) => heightProp + 10}px;
|
||||||
|
width: auto;
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
${props => props.deleting && deletingCss}
|
||||||
|
*, *:before, *:after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Content = styled.div`
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
position: relative;
|
||||||
|
transform: ${props => props.deleting && 'scale(0)'} translateX(${({ translate, rtl }) => (rtl ? 1 : 1) * translate}px);
|
||||||
|
${props => props.transition && `transition: transform ${props.transitionDuration}ms ease-out`}
|
||||||
|
`
|
||||||
|
|
||||||
|
export const Delete = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
top: 0;
|
||||||
|
background: ${({ deleteColor }) => deleteColor};
|
||||||
|
font-weight: 400;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
button {
|
||||||
|
width: ${({ deleteWidth }) => deleteWidth}px;
|
||||||
|
transition: margin ${({ transitionDuration }) => transitionDuration}ms ease-in-out;
|
||||||
|
${({ buttonMargin, rtl }) => `margin-${rtl ? 'right' : 'left'}: ${buttonMargin}px`};
|
||||||
|
text-align: center;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`
|
86
packages/app/src/components/UserRegister/index.jsx
Normal file
86
packages/app/src/components/UserRegister/index.jsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { StepsForm } from "components"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
key: "username",
|
||||||
|
title: "Step 1",
|
||||||
|
icon: "User",
|
||||||
|
description: "Enter the username for the account",
|
||||||
|
required: true,
|
||||||
|
content: (props) => {
|
||||||
|
return <div className="workorder_creator steps step content">
|
||||||
|
<antd.Input
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
onPressEnter={props.onPressEnter}
|
||||||
|
placeholder="@newuser"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.handleUpdate(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "password",
|
||||||
|
title: "Step 2",
|
||||||
|
icon: "Key",
|
||||||
|
description: "Enter a password for the account",
|
||||||
|
required: true,
|
||||||
|
content: (props) => {
|
||||||
|
return <div className="workorder_creator steps step content">
|
||||||
|
<antd.Input.Password
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
onPressEnter={props.onPressEnter}
|
||||||
|
placeholder="Password"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.handleUpdate(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
title: "Step 3",
|
||||||
|
icon: "Mail",
|
||||||
|
description: "Enter a email for the account",
|
||||||
|
required: true,
|
||||||
|
content: (props) => {
|
||||||
|
return <div className="workorder_creator steps step content">
|
||||||
|
<antd.Input
|
||||||
|
onPressEnter={props.onPressEnter}
|
||||||
|
placeholder="Email"
|
||||||
|
onChange={(e) => {
|
||||||
|
props.handleUpdate(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const api = window.app.request
|
||||||
|
|
||||||
|
const onSubmit = async (values) => {
|
||||||
|
const result = await api.post.register(values).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
props.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <StepsForm
|
||||||
|
steps={steps}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
/>
|
||||||
|
}
|
122
packages/app/src/components/UserSelector/index.jsx
Normal file
122
packages/app/src/components/UserSelector/index.jsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Translation } from "react-i18next"
|
||||||
|
import { SelectableList, Skeleton } from "components"
|
||||||
|
import { debounce } from "lodash"
|
||||||
|
import fuse from "fuse.js"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default class UserSelector extends React.Component {
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
data: [],
|
||||||
|
searchValue: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
api = window.app.request
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
this.toogleLoading(true)
|
||||||
|
await this.fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
toogleLoading = (to) => {
|
||||||
|
this.setState({ loading: to ?? !this.state.loading })
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUsers = async () => {
|
||||||
|
const data = await this.api.get.users(undefined, { select: this.props.select }).catch((err) => {
|
||||||
|
console.error(err)
|
||||||
|
antd.message.error("Error fetching operators")
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ data: data, loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
isExcludedId = (id) => {
|
||||||
|
if (this.props.excludedIds) {
|
||||||
|
return this.props.excludedIds.includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem = (item) => {
|
||||||
|
return <div disabled={this.isExcludedId(item._id)} className="user" >
|
||||||
|
<div><antd.Avatar shape="square" src={item.avatar} /></div>
|
||||||
|
<div><h1>{item.fullName ?? item.username}</h1></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
search = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
if (typeof value.target?.value === "string") {
|
||||||
|
value = value.target.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "") {
|
||||||
|
return this.setState({ searchValue: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const searcher = new fuse(this.state.data, {
|
||||||
|
includeScore: true,
|
||||||
|
keys: ["username", "fullName"],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = searcher.search(value)
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
searchValue: result.map((entry) => {
|
||||||
|
return entry.item
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncedSearch = debounce((value) => this.search(value), 500)
|
||||||
|
|
||||||
|
onSearch = (event) => {
|
||||||
|
if (event === "" && this.state.searchValue) {
|
||||||
|
return this.setState({ searchValue: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debouncedSearch(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.loading) {
|
||||||
|
return <Skeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="users_selector">
|
||||||
|
<div className="users_selector header">
|
||||||
|
<div>
|
||||||
|
<antd.Input.Search
|
||||||
|
placeholder="Search"
|
||||||
|
allowClear
|
||||||
|
onSearch={this.onSearch}
|
||||||
|
onChange={this.onSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SelectableList
|
||||||
|
onlyClickSelection
|
||||||
|
overrideSelectionEnabled
|
||||||
|
bulkSelectionAction
|
||||||
|
items={this.state.searchValue ?? this.state.data}
|
||||||
|
renderItem={this.renderItem}
|
||||||
|
actions={[
|
||||||
|
<div type="primary" call="onDone" key="done">
|
||||||
|
<Translation>
|
||||||
|
{t => t("Done")}
|
||||||
|
</Translation>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
events={{
|
||||||
|
onDone: (ctx, keys) => this.props.handleDone(keys),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
19
packages/app/src/components/UserSelector/index.less
Normal file
19
packages/app/src/components/UserSelector/index.less
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
.users_selector {
|
||||||
|
.header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
395
packages/app/src/components/formGenerator/index.jsx
Normal file
395
packages/app/src/components/formGenerator/index.jsx
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Icons } from "components/Icons"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Select,
|
||||||
|
Dropdown,
|
||||||
|
Slider,
|
||||||
|
InputNumber,
|
||||||
|
DatePicker,
|
||||||
|
AutoComplete,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
} from "antd"
|
||||||
|
import HeadShake from "react-reveal/HeadShake"
|
||||||
|
|
||||||
|
const allComponents = {
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Select,
|
||||||
|
Dropdown,
|
||||||
|
Slider,
|
||||||
|
InputNumber,
|
||||||
|
DatePicker,
|
||||||
|
AutoComplete,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class FormGenerator extends React.Component {
|
||||||
|
ref = React.createRef()
|
||||||
|
|
||||||
|
fieldsReferences = {}
|
||||||
|
unsetValues = {}
|
||||||
|
discardedValues = []
|
||||||
|
|
||||||
|
state = {
|
||||||
|
validating: false,
|
||||||
|
shakeItem: false,
|
||||||
|
failed: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
clearErrors: () => {
|
||||||
|
this.setState({ failed: {} })
|
||||||
|
},
|
||||||
|
clearForm: () => {
|
||||||
|
this.ctx.clearErrors()
|
||||||
|
this.ctx.toogleValidation(false)
|
||||||
|
this.ref.current.resetFields()
|
||||||
|
},
|
||||||
|
finish: () => this.ref.current.submit(),
|
||||||
|
error: (id, error) => {
|
||||||
|
this.handleFormError(id, error)
|
||||||
|
},
|
||||||
|
shake: (id) => {
|
||||||
|
this.formItemShake(id)
|
||||||
|
},
|
||||||
|
toogleValidation: (to) => {
|
||||||
|
if (typeof to !== "undefined") {
|
||||||
|
return this.setState({ validating: to })
|
||||||
|
}
|
||||||
|
this.setState({ validating: !this.state.validating })
|
||||||
|
|
||||||
|
return this.state.validating
|
||||||
|
},
|
||||||
|
formRef: this.ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFinish(payload) {
|
||||||
|
if (typeof this.props.onFinish !== "function") {
|
||||||
|
console.error(`onFinish is not an function`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to read unset values
|
||||||
|
Object.keys(this.fieldsReferences).forEach((key) => {
|
||||||
|
const ref = this.fieldsReferences[key].current
|
||||||
|
|
||||||
|
if (typeof ref.state !== "undefined") {
|
||||||
|
this.unsetValues[key] = ref.state?.value || ref.state?.checked
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// filter discarded values
|
||||||
|
try {
|
||||||
|
const keys = Object.keys(payload)
|
||||||
|
this.discardedValues.forEach((id) => {
|
||||||
|
if (keys.includes(id)) {
|
||||||
|
delete payload[id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
// terrible
|
||||||
|
}
|
||||||
|
|
||||||
|
// fulfil unset values
|
||||||
|
payload = { ...payload, ...this.unsetValues }
|
||||||
|
|
||||||
|
return this.props.onFinish(payload, this.ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
formItemShake(id) {
|
||||||
|
this.setState({ shakeItem: id })
|
||||||
|
setTimeout(() => {
|
||||||
|
this.setState({ shakeItem: false })
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFormError(item, error) {
|
||||||
|
let fails = this.state.failed
|
||||||
|
|
||||||
|
fails[item] = error ?? true
|
||||||
|
|
||||||
|
this.setState({ failed: fails })
|
||||||
|
this.formItemShake(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFailChange(event) {
|
||||||
|
const itemID = event.target.id
|
||||||
|
if (itemID) {
|
||||||
|
let fails = this.state.failed
|
||||||
|
|
||||||
|
if (fails["all"]) {
|
||||||
|
fails["all"] = false
|
||||||
|
this.setState({ failed: fails })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fails[itemID]) {
|
||||||
|
// try deactivate failed statement
|
||||||
|
fails[itemID] = false
|
||||||
|
this.setState({ failed: fails })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldShakeItem(id) {
|
||||||
|
try {
|
||||||
|
const mutation = false
|
||||||
|
if (this.state.shakeItem === "all") {
|
||||||
|
return mutation
|
||||||
|
}
|
||||||
|
if (this.state.shakeItem == id) {
|
||||||
|
return mutation
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// not returning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discardValueFromId = (id) => {
|
||||||
|
let ids = []
|
||||||
|
|
||||||
|
if (Array.isArray(id)) {
|
||||||
|
ids = id
|
||||||
|
} else {
|
||||||
|
ids.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.forEach((_id) => {
|
||||||
|
const value = this.discardedValues ?? []
|
||||||
|
value.push(_id)
|
||||||
|
this.discardedValues = value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderValidationIcon() {
|
||||||
|
if (this.props.renderLoadingIcon && this.state.validating) {
|
||||||
|
return <Icons.LoadingOutlined spin style={{ marginTop: "7px" }} />
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
renderElementPrefix = (element) => {
|
||||||
|
if (element.icon) {
|
||||||
|
let renderIcon = null
|
||||||
|
|
||||||
|
const iconType = typeof element.icon
|
||||||
|
switch (iconType) {
|
||||||
|
case "string": {
|
||||||
|
if (typeof Icons[element.icon] !== "undefined") {
|
||||||
|
renderIcon = React.createElement(Icons[element.icon])
|
||||||
|
} else {
|
||||||
|
console.warn("provided icon is not available on icons libs")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "object": {
|
||||||
|
renderIcon = element.icon
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
console.warn(`cannot mutate icon cause type (${iconType}) is not handled`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renderIcon) {
|
||||||
|
// try to generate icon with props
|
||||||
|
return React.cloneElement(renderIcon, element.iconProps ? { ...element.iconProps } : null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return element.prefix ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItems(elements) {
|
||||||
|
if (Array.isArray(elements)) {
|
||||||
|
try {
|
||||||
|
return elements.map((field) => {
|
||||||
|
let { item, element } = field
|
||||||
|
|
||||||
|
// if item has no id, return an uncontrolled field
|
||||||
|
if (typeof field.id === "undefined") {
|
||||||
|
return React.createElement(allComponents[element.component], element.props)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fulfill
|
||||||
|
if (typeof item === "undefined") {
|
||||||
|
item = {}
|
||||||
|
}
|
||||||
|
if (typeof element === "undefined") {
|
||||||
|
element = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if component is available on library
|
||||||
|
if (typeof allComponents[element.component] === "undefined") {
|
||||||
|
console.warn(`[${element.component}] is not an valid component`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle groups
|
||||||
|
if (typeof field.group !== "undefined") {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex" }} key={field.id}>
|
||||||
|
{this.renderItems(field.group)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//* RENDER
|
||||||
|
const failStatement = this.state.failed["all"] ?? this.state.failed[field.id]
|
||||||
|
|
||||||
|
const rules = item.rules
|
||||||
|
const hasFeedback = item.hasFeedback ?? false
|
||||||
|
|
||||||
|
let elementProps = {
|
||||||
|
disabled: this.state.validating,
|
||||||
|
...element.props,
|
||||||
|
}
|
||||||
|
let itemProps = {
|
||||||
|
...item.props,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (element.component) {
|
||||||
|
case "Checkbox": {
|
||||||
|
elementProps.onChange = (e) => {
|
||||||
|
this.unsetValues[field.id] = e.target.checked
|
||||||
|
elementProps.checked = e.target.checked
|
||||||
|
elementProps.value = e.target.checked
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "Button": {
|
||||||
|
this.discardValueFromId(field.id)
|
||||||
|
if (field.withValidation) {
|
||||||
|
elementProps.icon = this.state.validating ? (
|
||||||
|
<Icons.LoadingOutlined spin style={{ marginRight: "7px" }} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "Input": {
|
||||||
|
itemProps = {
|
||||||
|
...itemProps,
|
||||||
|
hasFeedback,
|
||||||
|
rules,
|
||||||
|
onChange: (e) => this.handleFailChange(e),
|
||||||
|
help: failStatement ? failStatement : null,
|
||||||
|
validateStatus: failStatement ? "error" : null,
|
||||||
|
}
|
||||||
|
elementProps = {
|
||||||
|
...elementProps,
|
||||||
|
id: field.id,
|
||||||
|
prefix: this.renderElementPrefix(element) ?? null,
|
||||||
|
placeholder: element.placeholder,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "Select": {
|
||||||
|
if (typeof element.renderItem !== "undefined") {
|
||||||
|
elementProps.children = element.renderItem
|
||||||
|
}
|
||||||
|
if (typeof element.options !== "undefined" && !element.renderItem) {
|
||||||
|
if (!Array.isArray(element.options)) {
|
||||||
|
console.warn(
|
||||||
|
`Invalid options data type, expecting Array > received ${typeof element.options}`,
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
elementProps.children = element.options.map((option) => {
|
||||||
|
return (
|
||||||
|
<Select.Option key={option.id ?? Math.random} value={option.value ?? option.id}>
|
||||||
|
{option.name ?? null}
|
||||||
|
</Select.Option>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
itemProps = {
|
||||||
|
...itemProps,
|
||||||
|
hasFeedback,
|
||||||
|
rules,
|
||||||
|
validateStatus: failStatement ? "error" : null,
|
||||||
|
help: failStatement ? failStatement : null,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
itemProps = {
|
||||||
|
...itemProps,
|
||||||
|
hasFeedback,
|
||||||
|
rules,
|
||||||
|
validateStatus: failStatement ? "error" : null,
|
||||||
|
help: failStatement ? failStatement : null,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// set reference
|
||||||
|
this.fieldsReferences[field.id] = elementProps.ref = React.createRef()
|
||||||
|
|
||||||
|
// return field
|
||||||
|
return (
|
||||||
|
<div className={field.className} style={field.style} key={field.id}>
|
||||||
|
{field.title ?? null}
|
||||||
|
<HeadShake spy={this.shouldShakeItem(field.id)}>
|
||||||
|
<Form.Item label={field.label} name={field.id} key={field.id} {...itemProps}>
|
||||||
|
{React.createElement(allComponents[element.component], elementProps)}
|
||||||
|
</Form.Item>
|
||||||
|
</HeadShake>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (!this.props.items) {
|
||||||
|
console.warn(`items not provided, nothing to render`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle discardedValues
|
||||||
|
if (Array.isArray(this.props.items)) {
|
||||||
|
this.props.items.forEach((item) => {
|
||||||
|
if (item.ignoreValue) {
|
||||||
|
this.discardValueFromId(item.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const helpStatus = this.state.failed["all"] ?? this.state.failed["result"]
|
||||||
|
const validateStatus = this.state.failed["all"] || this.state.failed["result"] ? "error" : null
|
||||||
|
|
||||||
|
if (!this.props.items) {
|
||||||
|
console.warn(`Nothing to render`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
hideRequiredMark={this.props.hideRequiredMark ?? false}
|
||||||
|
name={this.props.name ?? "new_form"}
|
||||||
|
onFinish={(e) => this.handleFinish(e)}
|
||||||
|
ref={this.ref}
|
||||||
|
{...this.props.formProps}
|
||||||
|
>
|
||||||
|
{this.renderItems(this.props.items)}
|
||||||
|
<Form.Item key="result" help={helpStatus} validateStatus={validateStatus} />
|
||||||
|
</Form>
|
||||||
|
{this.renderValidationIcon()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
32
packages/app/src/components/index.js
Normal file
32
packages/app/src/components/index.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export { default as FormGenerator } from "./FormGenerator"
|
||||||
|
export { default as Settings } from "./Settings"
|
||||||
|
export { default as NotFound } from "./NotFound"
|
||||||
|
export { default as AppSearcher } from "./AppSearcher"
|
||||||
|
export { default as RenderError } from "./RenderError"
|
||||||
|
export { default as ActionsBar } from "./ActionsBar"
|
||||||
|
export { default as SelectableList } from "./SelectableList"
|
||||||
|
export { default as ObjectInspector } from "./ObjectInspector"
|
||||||
|
export { default as ServerStatus } from "./ServerStatus"
|
||||||
|
export { default as ModifierTag } from "./ModifierTag"
|
||||||
|
export { default as UserSelector } from "./UserSelector"
|
||||||
|
export { default as Clock } from "./Clock"
|
||||||
|
export { default as StepsForm } from "./StepsForm"
|
||||||
|
export { default as DraggableDrawer } from "./DraggableDrawer"
|
||||||
|
export { default as AddableSelectList } from "./AddableSelectList"
|
||||||
|
export { default as SwipeItem } from "./SwipeItem"
|
||||||
|
export { default as Crash } from "./Crash"
|
||||||
|
export { default as SearchButton } from "./SearchButton"
|
||||||
|
export { default as UserRegister } from "./UserRegister"
|
||||||
|
export { default as Skeleton } from "./Skeleton"
|
||||||
|
export { default as Navigation } from "./Navigation"
|
||||||
|
export { default as ImageUploader } from "./ImageUploader"
|
||||||
|
export { default as ImageViewer } from "./ImageViewer"
|
||||||
|
|
||||||
|
export { default as PostsFeed } from "./PostsFeed"
|
||||||
|
export { default as LikeButton } from "./LikeButton"
|
||||||
|
export { default as PostCard } from "./PostCard"
|
||||||
|
export { default as PostCreator } from "./PostCreator"
|
||||||
|
|
||||||
|
export * as AdminTools from "./AdminTools"
|
||||||
|
export * as AboutApp from "./AboutApp"
|
||||||
|
export * as Window from "./RenderWindow"
|
76
packages/app/src/components/modifierTag/index.jsx
Normal file
76
packages/app/src/components/modifierTag/index.jsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import { Icons, createIconRender } from "components/Icons"
|
||||||
|
|
||||||
|
export default (props) => {
|
||||||
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
const [options, setOptions] = React.useState([])
|
||||||
|
const [value, setValue] = React.useState(null)
|
||||||
|
|
||||||
|
const onChangeProperties = async (update) => {
|
||||||
|
if (props.eventDisable) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
await props.onChangeProperties(update)
|
||||||
|
.then((data) => {
|
||||||
|
return setValue(update.join("-"))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
return
|
||||||
|
})
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTagColor = () => {
|
||||||
|
if (props.colors) {
|
||||||
|
return props.colors[value]
|
||||||
|
}
|
||||||
|
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOptionsLoad = async (fn) => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const result = await fn()
|
||||||
|
setOptions(result)
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDefaultValueLoad = async (fn) => {
|
||||||
|
const result = await fn()
|
||||||
|
setValue(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (typeof props.options === "function") {
|
||||||
|
handleOptionsLoad(props.options)
|
||||||
|
} else {
|
||||||
|
setOptions(props.options)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof props.defaultValue === "function") {
|
||||||
|
handleDefaultValueLoad(props.defaultValue)
|
||||||
|
} else {
|
||||||
|
setValue(props.defaultValue)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <antd.Cascader options={options} onChange={(update) => onChangeProperties(update)} >
|
||||||
|
<antd.Tag color={getTagColor()}>
|
||||||
|
{loading ? <Icons.LoadingOutlined spin /> :
|
||||||
|
<>
|
||||||
|
{Icons[props.icon] && createIconRender(props.icon)}
|
||||||
|
<h4>
|
||||||
|
{value}
|
||||||
|
</h4>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</antd.Tag>
|
||||||
|
</antd.Cascader>
|
||||||
|
}
|
12
packages/app/src/components/notFound/index.jsx
Normal file
12
packages/app/src/components/notFound/index.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Result } from "antd"
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="404"
|
||||||
|
title="404"
|
||||||
|
subTitle="Sorry, the page you visited does not exist."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
1
packages/app/src/debugComponents/index.js
Normal file
1
packages/app/src/debugComponents/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as ThemeDebug } from "./theme"
|
56
packages/app/src/debugComponents/theme/index.jsx
Normal file
56
packages/app/src/debugComponents/theme/index.jsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React from "react"
|
||||||
|
import * as antd from "antd"
|
||||||
|
import ReactJSON from "react-json-view"
|
||||||
|
|
||||||
|
import { Theme } from "extensions"
|
||||||
|
|
||||||
|
import "./index.less"
|
||||||
|
|
||||||
|
export default class ThemeDebug extends React.Component {
|
||||||
|
state = {
|
||||||
|
currentVariant: null,
|
||||||
|
rootVariables: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
await this.setValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
setValues = async () => {
|
||||||
|
const currentVariant = document.documentElement.style.getPropertyValue("--themeVariant")
|
||||||
|
const rootVariables = window.app.ThemeController.getRootVariables()
|
||||||
|
|
||||||
|
this.setState({ currentVariant, rootVariables })
|
||||||
|
}
|
||||||
|
|
||||||
|
editValues = async (values) => {
|
||||||
|
console.log(values)
|
||||||
|
await window.app.ThemeController.update({ [values.name]: values.new_value })
|
||||||
|
await this.setState({ rootVariables: values.updated_src })
|
||||||
|
}
|
||||||
|
|
||||||
|
setDefaults = async () => {
|
||||||
|
await window.app.ThemeController.resetDefault()
|
||||||
|
await this.setValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return <div className="themeDebugger">
|
||||||
|
<div>
|
||||||
|
<antd.Button onClick={this.setDefaults}>
|
||||||
|
default
|
||||||
|
</antd.Button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Current variant: <antd.Tag>{this.state.currentVariant}</antd.Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ReactJSON
|
||||||
|
src={this.state.rootVariables}
|
||||||
|
onEdit={this.editValues}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
8
packages/app/src/debugComponents/theme/index.less
Normal file
8
packages/app/src/debugComponents/theme/index.less
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.themeDebugger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
187
packages/app/src/extensions/api/index.js
Normal file
187
packages/app/src/extensions/api/index.js
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import config from "config"
|
||||||
|
import { Bridge } from "linebridge/dist/client"
|
||||||
|
import { Session } from "models"
|
||||||
|
import io from "socket.io-client"
|
||||||
|
|
||||||
|
class WSInterface {
|
||||||
|
constructor(params = {}) {
|
||||||
|
this.params = params
|
||||||
|
this.manager = new io.Manager(this.params.origin, {
|
||||||
|
autoConnect: true,
|
||||||
|
transports: ["websocket"],
|
||||||
|
...this.params.managerOptions,
|
||||||
|
})
|
||||||
|
this.sockets = {}
|
||||||
|
|
||||||
|
this.register("/", "main")
|
||||||
|
}
|
||||||
|
|
||||||
|
register = (socket, as) => {
|
||||||
|
if (typeof socket !== "string") {
|
||||||
|
console.error("socket must be string")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
socket = this.manager.socket(socket)
|
||||||
|
return this.sockets[as ?? socket] = socket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
key: "apiBridge",
|
||||||
|
expose: [
|
||||||
|
{
|
||||||
|
initialization: [
|
||||||
|
async (app, main) => {
|
||||||
|
app.apiBridge = await app.createApiBridge()
|
||||||
|
|
||||||
|
app.WSInterface = app.apiBridge.wsInterface
|
||||||
|
app.WSInterface.request = app.WSRequest
|
||||||
|
app.WSInterface.listen = app.handleWSListener
|
||||||
|
app.WSSockets = app.WSInterface.sockets
|
||||||
|
app.WSInterface.mainSocketConnected = false
|
||||||
|
|
||||||
|
app.WSSockets.main.on("authenticated", () => {
|
||||||
|
console.debug("[WS] Authenticated")
|
||||||
|
})
|
||||||
|
app.WSSockets.main.on("authenticateFailed", (error) => {
|
||||||
|
console.error("[WS] Authenticate Failed", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.WSSockets.main.on("connect", () => {
|
||||||
|
window.app.eventBus.emit("websocket_connected")
|
||||||
|
app.WSInterface.mainSocketConnected = true
|
||||||
|
})
|
||||||
|
|
||||||
|
app.WSSockets.main.on("disconnect", (...context) => {
|
||||||
|
window.app.eventBus.emit("websocket_disconnected", ...context)
|
||||||
|
app.WSInterface.mainSocketConnected = false
|
||||||
|
})
|
||||||
|
|
||||||
|
app.WSSockets.main.on("connect_error", (...context) => {
|
||||||
|
window.app.eventBus.emit("websocket_connection_error", ...context)
|
||||||
|
app.WSInterface.mainSocketConnected = false
|
||||||
|
})
|
||||||
|
|
||||||
|
window.app.api = app.apiBridge
|
||||||
|
window.app.ws = app.WSInterface
|
||||||
|
|
||||||
|
window.app.request = app.apiBridge.endpoints
|
||||||
|
window.app.wsRequest = app.apiBridge.wsEndpoints
|
||||||
|
},
|
||||||
|
],
|
||||||
|
mutateContext: {
|
||||||
|
async attachWSConnection() {
|
||||||
|
if (!this.WSInterface.sockets.main.connected) {
|
||||||
|
await this.WSInterface.sockets.main.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime = null
|
||||||
|
let latency = null
|
||||||
|
let latencyWarning = false
|
||||||
|
|
||||||
|
let pingInterval = setInterval(() => {
|
||||||
|
if (!this.WSInterface.mainSocketConnected) {
|
||||||
|
return clearTimeout(pingInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime = Date.now()
|
||||||
|
this.WSInterface.sockets.main.emit("ping")
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
this.WSInterface.sockets.main.on("pong", () => {
|
||||||
|
latency = Date.now() - startTime
|
||||||
|
|
||||||
|
if (latency > 800 && this.WSInterface.mainSocketConnected) {
|
||||||
|
latencyWarning = true
|
||||||
|
console.error("[WS] Latency is too high > 800ms", latency)
|
||||||
|
window.app.eventBus.emit("websocket_latency_too_high", latency)
|
||||||
|
} else if (latencyWarning && this.WSInterface.mainSocketConnected) {
|
||||||
|
latencyWarning = false
|
||||||
|
window.app.eventBus.emit("websocket_latency_normal", latency)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async attachAPIConnection() {
|
||||||
|
await this.apiBridge.initialize()
|
||||||
|
},
|
||||||
|
handleWSListener: (to, fn) => {
|
||||||
|
if (typeof to === "undefined") {
|
||||||
|
console.error("handleWSListener: to must be defined")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (typeof fn !== "function") {
|
||||||
|
console.error("handleWSListener: fn must be function")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let ns = "main"
|
||||||
|
let event = null
|
||||||
|
|
||||||
|
if (typeof to === "string") {
|
||||||
|
event = to
|
||||||
|
} else if (typeof to === "object") {
|
||||||
|
ns = to.ns
|
||||||
|
event = to.event
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.app.ws.sockets[ns].on(event, async (...context) => {
|
||||||
|
return await fn(...context)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
createApiBridge: async () => {
|
||||||
|
const getSessionContext = async () => {
|
||||||
|
const obj = {}
|
||||||
|
const token = await Session.token
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// append token to context
|
||||||
|
obj.headers = {
|
||||||
|
Authorization: `Bearer ${token ?? null}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResponse = async (data) => {
|
||||||
|
if (data.headers?.regenerated_token) {
|
||||||
|
Session.token = data.headers.regenerated_token
|
||||||
|
console.debug("[REGENERATION] New token generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data instanceof Error) {
|
||||||
|
if (data.response.status === 401) {
|
||||||
|
window.app.eventBus.emit("invalid_session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = new Bridge({
|
||||||
|
origin: config.api.address,
|
||||||
|
wsOrigin: config.ws.address,
|
||||||
|
wsOptions: {
|
||||||
|
autoConnect: false,
|
||||||
|
},
|
||||||
|
onRequest: getSessionContext,
|
||||||
|
onResponse: handleResponse,
|
||||||
|
})
|
||||||
|
|
||||||
|
return bridge
|
||||||
|
},
|
||||||
|
WSRequest: (socket = "main", channel, ...args) => {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const request = await window.app.ws.sockets[socket].emit(channel, ...args)
|
||||||
|
|
||||||
|
request.on("responseError", (...errors) => {
|
||||||
|
return reject(...errors)
|
||||||
|
})
|
||||||
|
request.on("response", (...responses) => {
|
||||||
|
return resolve(...responses)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
135
packages/app/src/extensions/debug/index.jsx
Normal file
135
packages/app/src/extensions/debug/index.jsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { Window } from "components"
|
||||||
|
import { Skeleton, Tabs } from "antd"
|
||||||
|
|
||||||
|
class DebuggerUI extends React.Component {
|
||||||
|
state = {
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
debuggers: null,
|
||||||
|
active: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
toogleLoading = (to = !this.state.loading ?? false) => {
|
||||||
|
this.setState({ loading: to })
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDebuggers = async () => {
|
||||||
|
this.toogleLoading(true)
|
||||||
|
|
||||||
|
const debuggers = await import(`~/debugComponents`)
|
||||||
|
let renders = {}
|
||||||
|
|
||||||
|
Object.keys(debuggers).forEach((key) => {
|
||||||
|
renders[key] = debuggers[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setState({ debuggers: renders }, () => {
|
||||||
|
this.toogleLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount = async () => {
|
||||||
|
await this.loadDebuggers()
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch = (error, info) => {
|
||||||
|
this.setState({ error })
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeTab = (key) => {
|
||||||
|
console.debug(`Changing tab to ${key}`)
|
||||||
|
this.setState({ active: key, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
renderError = (key, error) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Debugger Error</h2>
|
||||||
|
<i>
|
||||||
|
<h4>
|
||||||
|
Catch on [<strong>{key}</strong>]
|
||||||
|
</h4>
|
||||||
|
</i>
|
||||||
|
`<code>{error.message}</code>`
|
||||||
|
<hr />
|
||||||
|
<code>{error.stack}</code>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTabs = () => {
|
||||||
|
return Object.keys(this.state.debuggers).map((key) => {
|
||||||
|
return <Tabs.TabPane tab={key} key={key} />
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDebugger = (_debugger) => {
|
||||||
|
try {
|
||||||
|
return React.createElement(window.app.bindContexts(_debugger))
|
||||||
|
} catch (error) {
|
||||||
|
return this.renderError(key, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { loading, error } = this.state
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Skeleton active />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tabs
|
||||||
|
onChange={this.onChangeTab}
|
||||||
|
activeKey={this.state.active}
|
||||||
|
>
|
||||||
|
{this.renderTabs()}
|
||||||
|
</Tabs>
|
||||||
|
{error && this.renderError(this.state.active, error)}
|
||||||
|
{!this.state.active ? (
|
||||||
|
<div> Select an debugger to start </div>
|
||||||
|
) : (
|
||||||
|
this.renderDebugger(this.state.debuggers[this.state.active])
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Debugger {
|
||||||
|
constructor(mainContext, params = {}) {
|
||||||
|
this.mainContext = mainContext
|
||||||
|
this.params = { ...params }
|
||||||
|
|
||||||
|
this.bindings = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
openWindow = () => {
|
||||||
|
new Window.DOMWindow({ id: "debugger", children: window.app.bindContexts(DebuggerUI) }).create()
|
||||||
|
}
|
||||||
|
|
||||||
|
bind = (id, binding) => {
|
||||||
|
this.bindings[id] = binding
|
||||||
|
|
||||||
|
return binding
|
||||||
|
}
|
||||||
|
|
||||||
|
unbind = (id) => {
|
||||||
|
delete this.bindings[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
key: "visualDebugger",
|
||||||
|
expose: [
|
||||||
|
{
|
||||||
|
initialization: [
|
||||||
|
async (app, main) => {
|
||||||
|
main.setToWindowContext("debug", new Debugger(main))
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user