Merge pull request #54 from ragestudio/rewrite

Rewrite
This commit is contained in:
srgooglo 2022-03-14 23:03:39 +01:00 committed by GitHub
commit 0e6889e676
383 changed files with 14248 additions and 33313 deletions

3
.corenode Executable file
View File

@ -0,0 +1,3 @@
{
"version": "0.13.0"
}

View File

@ -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

2
.env
View File

@ -1,2 +0,0 @@
UMI_UI=false
NODE_ENV=development

49
.gitignore vendored Executable file → Normal file
View File

@ -1,26 +1,29 @@
# dependencies
/node_modules
/npm-debug.log*
/yarn-error.log
/yarn.lock
# Secrets
/**/**/.env
/**/**/origin.server
/**/**/server.manifest
/**/**/server.registry
# production
/build
/dist
/out
# Trash
/**/**/.crash.log
/**/**/.tmp
/**/**/.cache
/**/**/out
/**/**/.out
/**/**/dist
/**/**/node_modules
/**/**/corenode_modules
/**/**/.DS_Store
/**/**/package-lock.json
/**/**/yarn.lock
/**/**/.evite
/**/**/d_data
# umi
/packages/**/src/.umi
/packages/**/src/.umi-production
/packages/**/src/.umi-test
/packages/**/.env.local
# Logs
/**/**/npm-debug.log*
/**/**/yarn-error.log
/**/**/dumps.log
/**/**/corenode.log
/packages/*/src/.umi
/packages/*/src/.umi-production
/packages/*/src/.umi-test
/packages/*/.env.local
/.env.local
/packages/*/node_modules
/packages/**/node_modules
# Temporal configurations
/**/**/.aliaser

View File

@ -1,11 +0,0 @@
{
"version": "0.12.8",
"devRuntime": {
"headPackage": "ragestudio"
},
"runtime": {
"src": "/src",
"UUID": "C8mVSr-4nmPp2-pr5Vrz-CU4kg4",
"stage": "alpha"
}
}

View File

@ -1,8 +0,0 @@
**/*.md
**/*.svg
**/*.ejs
**/*.html
package.json
.umi
.umi-production
.umi-test

View File

@ -1,11 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"overrides": [
{
"files": ".prettierrc",
"options": { "parser": "json" }
}
]
}

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -6,27 +6,8 @@
"workspaces": [
"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": {
"@ragestudio/nodecorejs": "^0.15.1"
}
"corenode": "^0.28.26"
},
"version": "0.13.0"
}

69
packages/app/.config.js Normal file
View 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
View File

@ -0,0 +1,8 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.vscode
yarn-error.log
ios/**/**

View File

@ -0,0 +1,7 @@
{
"appId": "com.ragestudio.comty",
"appName": "Comty",
"bundledWebRuntime": true,
"overrideUserAgent": "capacitor",
"webDir": "dist"
}

View 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",
}
}

View File

@ -0,0 +1,4 @@
{
"http_api": "https://indev_api.comty.pw",
"ws_api": "wss://indev_ws.comty.pw"
}

View 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"
]
}

View File

@ -0,0 +1,5 @@
{
"crash": "/sounds/crash.wav",
"error": "/sounds/error.wav",
"notification": "/sounds/notification.wav"
}

View 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)"
}
}
}

View File

@ -0,0 +1,14 @@
{
"main": {
"icon": "Home",
"title": "Main"
},
"account": {
"icon": "User",
"title": "Account"
},
"users": {
"icon": "Users",
"title": "Users"
}
}

View 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"
}
]

View 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",
},
]

View 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,
}
]

View 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
View 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
View 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"
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View 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

View 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

430
packages/app/src/App.jsx Normal file
View 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,
],
})

View 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)
}

View 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;
}
}
}
}

View 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>
}

View 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;
}
}
}

View 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>
}
}

View 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;
}
}

View 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>
)
}
}

View File

@ -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)
}
}

View File

@ -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>
}
}

View File

@ -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;
}
}

View 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()
}
})
})
}
}

View 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>
)
}
}

View 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;
}
}

View 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>
}

View 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>
}

View 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";
}

View 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%;
`

View 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

View 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>
}
}

View 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

View 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;
}
}
}

View 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>
}

View 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);
}
}

View 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>
}
}

View 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;
}
}
}

View File

@ -0,0 +1,8 @@
import React from "react"
import { notification } from "antd"
export default {
error: (...context) => {
notification.error(context)
},
}

View 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>
)
}
}

View 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

View 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;
}
}

View 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>
}
}

View 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%;
}
}
}

View 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>
}
}

View 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;
}
}
}
}

View 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>
)
}

View 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;
}
}
}

View 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 }

View 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;
}
}

View 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>
}
}

View 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);
}
}

View 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>
}

View 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;
}
}

View 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>
}
}

View 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;
}

View 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>
}

View 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>
)
}
}

View 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;
}
}

View 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>
}

View 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;
}

View 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>
)
}
}

View 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;
}
}
}

View 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

View 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;
}
`

View 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}
/>
}

View 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>
}
}

View 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;
}
}
}

View 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>
)
}
}

View 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"

View 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>
}

View 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."
/>
)
}

View File

@ -0,0 +1 @@
export { default as ThemeDebug } from "./theme"

View 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>
}
}

View File

@ -0,0 +1,8 @@
.themeDebugger {
display: flex;
flex-direction: column;
> div {
margin-bottom: 20px;
}
}

View 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)
})
})
}
},
},
],
}

View 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