diff --git a/packages/app/.config.js b/packages/app/.config.js index 559c8337..1f2929a0 100644 --- a/packages/app/.config.js +++ b/packages/app/.config.js @@ -1,16 +1,17 @@ -const path = require('path') +const path = require("path") const aliases = { - '~/': `${path.resolve(__dirname, 'src')}/`, + "~/": `${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'), + "@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 = {}) => { @@ -22,12 +23,14 @@ module.exports = (config = {}) => { } config.resolve.alias = aliases - config.server.port = 8000 + 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: { diff --git a/packages/app/capacitor.config.json b/packages/app/capacitor.config.json index 907634ec..35900907 100644 --- a/packages/app/capacitor.config.json +++ b/packages/app/capacitor.config.json @@ -3,10 +3,5 @@ "appName": "Comty", "bundledWebRuntime": true, "overrideUserAgent": "capacitor", - "webDir": "dist", - "server": { - "hostname": "192.168.0.5:8000", - "iosScheme": "http", - "androidScheme": "http" - } + "webDir": "dist" } \ No newline at end of file diff --git a/packages/app/package.json b/packages/app/package.json index 29a9d7a9..31c2c5ab 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -5,64 +5,79 @@ "scripts": { "dev": "vite", "dev:ssr": "vite-ssr", - "dev:evite": "evite dev", - "build": "cross-env NODE_ENV=production vite build", + "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", - "@corenode/utils": "^0.28.26", - "@react-pdf/renderer": "^1.6.17", - "antd": "^4.17.4", - "chart.js": "^3.5.1", - "classnames": "^2.3.1", - "dicebar_lib": "^1.0.0", - "enquire-js": "^0.2.1", - "evite-react-lib": "^0.9.0", - "faye": "^1.4.0", - "feather-reactjs": "^2.0.13", - "fuse.js": "^6.4.6", - "hls.js": "^1.0.12", - "howler": "^2.2.3", - "i18next": "^20.3.5", - "js-cookie": "^2.2.1", - "less": "^4.1.2", - "linebridge": "^0.8.4", - "moment": "^2.29.1", + "@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.18.6", + "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", - "plyr": "^3.6.9", - "qrcode": "^1.4.4", - "@foxify/events": "^2.0.1", - "@loadable/component": "^5.15.0", - "history": "^5.0.1", - "jwt-decode": "^3.1.2", - "react": "^17.0.2", - "react-helmet": "^6.1.0", - "react-beautiful-dnd": "^13.1.0", - "react-chartjs-2": "^3.2.0", - "react-color": "^2.19.3", - "react-contexify": "^5.0.0", - "react-dom": "^17.0.2", - "react-draggable": "^4.4.3", - "react-helmet-async": "^1.0.9", - "react-i18next": "^11.11.4", - "react-icons": "^4.3.1", - "react-json-view": "^1.21.3", - "react-qr-reader": "^2.2.1", - "react-reveal": "^1.2.2", - "react-rnd": "^10.3.5", - "react-router": "^5.2.0", - "react-router-config": "^5.1.1", - "react-router-dom": "^5.2.0", - "socket.io-client": "^4.2.0", - "vite-ssr": "^0.14.3" + "nprogress": "^0.2.0", + "plyr": "^3.6.12", + "prop-types": "^15.8.1", + "qrcode": "1.5.0", + "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", + "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/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", @@ -71,11 +86,11 @@ "@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", + "corenode": "0.28.26", "cross-env": "^7.0.3", - "evite": "^0.8.8", + "express": "^4.17.1", "typescript": "^4.3.5", - "vite": "^2.7.1", + "vite": "2.7.13", "vite-plugin-pages": "0.12.x" } } diff --git a/packages/app/src/App.jsx b/packages/app/src/App.jsx index 5fd1f183..a3c4bbbb 100644 --- a/packages/app/src/App.jsx +++ b/packages/app/src/App.jsx @@ -1,4 +1,13 @@ // 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 @@ -10,19 +19,45 @@ String.prototype.toTitleCase = function () { }) } +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-react-lib" +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, SidebarController, SettingsController } from "models" -import { API, Render, Splash, Theme, Sound } from "extensions" +import { Session, User } from "models" +import { API, SettingsController, Render, Splash, Theme, Sound, Notifications, i18n } from "extensions" import config from "config" -import { NotFound, RenderError, Settings } from "components" -import Layout from "./layout" +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({ @@ -39,118 +74,115 @@ const SplashExtension = Splash.extension({ }, }) -class ThrowCrash { - constructor(message, description) { - this.message = message - this.description = description - - antd.notification.error({ - message: "Fatal error", - description: message, - }) - - window.app.eventBus.emit("crash", this.message, this.description) - } -} - -const __renderTest = () => { - const [position, setPosition] = React.useState(0) - - // create a 300ms interval to move randomly inside window screen - React.useEffect(() => { - setInterval(() => { - const x = Math.random() * window.innerWidth - const y = Math.random() * window.innerHeight - - setPosition({ x, y }) - }, 50) - }, []) - - // clear interval when component unmount - React.useEffect(() => { - return () => { - clearInterval() - } - }, []) - - return
-} - class App { static initialize() { - this.configuration = { - settings: new SettingsController(), - sidebar: new SidebarController(), - } + window.app.version = config.package.version - this.eventBus = this.contexts.main.eventBus + this.mainSocket = this.contexts.app.WSInterface.sockets.main + this.loadingMessage = false + this.isAppCapacitor = () => navigator.userAgent === "capacitor" + } - this.eventBus.on("app_loading", async () => { - await this.setState({ initialized: false }) - this.eventBus.emit("splash_show") - }) - - this.eventBus.on("app_ready", async () => { - await this.setState({ initialized: true }) - this.eventBus.emit("splash_close") - }) - - this.eventBus.on("reinitializeSession", async () => { - await this.__SessionInit() - }) - this.eventBus.on("reinitializeUser", async () => { - await this.__UserInit() - }) - - this.eventBus.on("forceToLogin", () => { - if (window.location.pathname !== "/login") { - this.beforeLoginLocation = window.location.pathname - } - - window.app.setLocation("/login") - }) - - this.eventBus.on("new_session", async () => { + 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 } - }) - this.eventBus.on("destroyed_session", async () => { + }, + "destroyed_session": async function () { await this.flushState() this.eventBus.emit("forceToLogin") - }) - - this.eventBus.on("invalid_session", (error) => { + }, + "forceToLogin": function () { if (window.location.pathname !== "/login") { - this.sessionController.forgetLocalSession() + 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: "Invalid Session", - description: error, + message: + {(t) => t("Invalid Session")} + , + description: + {(t) => t(error)} + , icon: , }) - - this.eventBus.emit("forceToLogin") } - }) - - this.eventBus.on("cleanAll", () => { + }, + "clearAllOverlays": function () { window.app.DrawerController.closeAll() - }) + }, + "websocket_connected": function () { + if (this.wsReconnecting) { + this.wsReconnectingTry = 0 + this.wsReconnecting = false + this.initialization() - this.eventBus.on("crash", (message, error) => { - console.debug("[App] crash detecting, returning crash...") + setTimeout(() => { + Toast.show({ + icon: "success", + content: "Connected", + }) + }, 500) + } + }, + "websocket_connection_error": function () { + if (!this.wsReconnecting) { + this.latencyWarning = null + this.wsReconnectingTry = 0 + this.wsReconnecting = true - this.setState({ crash: { message, error } }) - this.contexts.app.SoundEngine.play("crash") - }) + 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", + }) + } + } } static windowContext() { return { + // TODO: Open with popup controller instead drawer controller + openNavigationMenu: () => window.app.DrawerController.open("navigation", Navigation), openSettings: (goTo) => { window.app.DrawerController.open("settings", Settings, { props: { @@ -167,8 +199,35 @@ class App { goToAccount: (username) => { return window.app.setLocation(`/account`, { username }) }, - configuration: this.configuration, - getSettings: (...args) => this.contexts.app.configuration?.settings?.get(...args), + 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, } } @@ -177,7 +236,6 @@ class App { renderRef: this.renderRef, sessionController: this.sessionController, userController: this.userController, - configuration: this.configuration, } } @@ -188,100 +246,129 @@ class App { RenderError: (props) => { return }, + Crash: Crash, initialization: () => { return } } sessionController = new Session() - userController = new User() - state = { - // app - initialized: false, - crash: false, - - // app session session: null, - data: null, + user: null, } flushState = async () => { - await this.setState({ session: null, data: null }) + await this.setState({ session: null, user: null }) } componentDidMount = async () => { - this.eventBus.emit("app_loading") + if (this.isAppCapacitor()) { + window.addEventListener("statusTap", () => { + this.eventBus.emit("statusTap") + }) + + StatusBar.setOverlaysWebView({ overlay: true }) + window.app.hideStatusBar() + } + + this.eventBus.emit("render_initialization") - await this.contexts.app.initializeDefaultBridge() await this.initialization() - this.eventBus.emit("app_ready") + this.eventBus.emit("render_initialization_done") } initialization = async () => { - try { - await this.__SessionInit() - await this.__UserInit() - } catch (error) { - throw new ThrowCrash(error.message, error.description) - } + 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("crash", reason.cause, reason.details) + }) } __SessionInit = async () => { - if (typeof Session.token === "undefined") { + const token = await Session.token + + if (!token || token == null) { window.app.eventBus.emit("forceToLogin") - } else { - this.session = await this.sessionController.getTokenInfo().catch((error) => { - window.app.eventBus.emit("invalid_session", error) - }) - - if (!this.session.valid) { - // try to regenerate - //const regeneration = await this.sessionController.regenerateToken() - //console.log(regeneration) - - window.app.eventBus.emit("invalid_session", this.session.error) - } - } - - this.setState({ session: this.session }) - } - - __UserInit = async () => { - if (!this.session || !this.session.valid) { return false } - try { - this.user = await User.data - this.setState({ user: this.user }) - } catch (error) { - console.error(error) - this.eventBus.emit("crash", "Cannot initialize user data", error) + 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() { - if (!this.state.initialized) { - return null - } - - if (this.state.crash) { - return
-
- -

Crash

-
-

{this.state.crash.message}

-
{this.state.crash.error}
-
- window.location.reload()}>Reload -
-
- } - return ( @@ -303,5 +390,14 @@ class App { } export default CreateEviteApp(App, { - extensions: [Sound.extension, Render.extension, Theme.extension, API, SplashExtension], + extensions: [ + SettingsController, + i18n.extension, + Sound.extension, + Notifications.extension, + API, + Render.extension, + Theme.extension, + SplashExtension, + ], }) \ No newline at end of file diff --git a/packages/app/src/components/AboutApp/index.jsx b/packages/app/src/components/AboutApp/index.jsx index cf841fb2..b845dad4 100644 --- a/packages/app/src/components/AboutApp/index.jsx +++ b/packages/app/src/components/AboutApp/index.jsx @@ -1,72 +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 class AboutCard extends React.Component { - state = { - visible: true, +export const AboutCard = (props) => { + const [visible, setVisible] = React.useState(false) + + React.useEffect(() => { + setVisible(true) + }, []) + + const close = () => { + setVisible(false) + setTimeout(() => { + props.onClose() + }, 150) } - onClose = () => { - this.setState({ visible: false }) + const isProduction = import.meta.env.PROD + const isWSMainConnected = window.app.ws.mainSocketConnected + const WSMainOrigin = app.ws.sockets.main.io.uri - if (typeof this.props.onClose === "function") { - this.props.onClose() - } - } - - render() { - const eviteNamespace = window.__evite ?? {} - const appConfig = config.app ?? {} - const isDevMode = eviteNamespace?.env?.NODE_ENV !== "production" - - return ( - -
-
-
- + return close()}> +
+ +
+

{config.app.siteName}

+ {config.author}
-

{appConfig.siteName}

-
- - v{eviteNamespace?.projectVersion ?? " experimental"} - - {eviteNamespace.eviteVersion && - eVite v{eviteNamespace?.eviteVersion}} - {eviteNamespace.version?.node && - v{eviteNamespace?.versions?.node} - } - - {isDevMode ? : } - {isDevMode ? "development" : "stable"} - -
+ v{window.app.version ?? "experimental"} + + {isProduction ? : } + {String(import.meta.env.MODE)} +
-
+ } + > +
+

Server information

+
+ {WSMainOrigin} +
- - ) - } +
+

Versions

+
+ eVite v{window.__eviteVersion ?? "experimental"} + v{React.version ?? "experimental"} +
+
+ +
+ } export function openModal() { const component = document.createElement("div") document.body.appendChild(component) - ReactDOM.render(, component) -} + const onClose = () => { + ReactDOM.unmountComponentAtNode(component) + document.body.removeChild(component) + } + + ReactDOM.render(, component) +} \ No newline at end of file diff --git a/packages/app/src/components/AboutApp/index.less b/packages/app/src/components/AboutApp/index.less index 538a4a46..689fedfa 100644 --- a/packages/app/src/components/AboutApp/index.less +++ b/packages/app/src/components/AboutApp/index.less @@ -1,36 +1,75 @@ -.about_app_wrapper { +.aboutApp_wrapper { display: flex; flex-direction: column; + justify-content: center; + align-items: center; + width: 100vw; + height: 100vh; } -.about_app_header { - width: 100%; +.aboutApp_card { + height: fit-content; + width: 80vw; - display: flex; - flex-direction: row; - align-items: baseline; - - margin: 20px 20px 80px 20px; - - img { - height: fit-content; - width: 200px; - - max-height: 200px; - max-width: 200px; + svg { + margin: 0; } - h1 { - font-size: 55px; + .ant-tag { + display: inline-flex; + align-items: center; } - - > div { - padding-right: 50px; - } - -} - -.about_app_info { + .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; + } + } + + } } \ No newline at end of file diff --git a/packages/app/src/components/ActionsBar/index.jsx b/packages/app/src/components/ActionsBar/index.jsx index c998765b..e064d824 100644 --- a/packages/app/src/components/ActionsBar/index.jsx +++ b/packages/app/src/components/ActionsBar/index.jsx @@ -5,8 +5,22 @@ import "./index.less" export default (props) => { const { children } = props - return
-
+ return
+
{children}
diff --git a/packages/app/src/components/ActionsBar/index.less b/packages/app/src/components/ActionsBar/index.less index 2eed8f70..4612d359 100644 --- a/packages/app/src/components/ActionsBar/index.less +++ b/packages/app/src/components/ActionsBar/index.less @@ -1,6 +1,7 @@ @actionsBar_height: fit-content; -.actionsBar_card { +.actionsBar { + --ignore-dragger: true; display: inline-block; white-space: nowrap; @@ -15,7 +16,9 @@ border-radius: 8px; background-color: #0c0c0c15; + backdrop-filter: blur(10px); + --webkit-backdrop-filter: blur(10px); transition: all 200ms ease-in-out; @@ -28,6 +31,28 @@ 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; @@ -56,20 +81,20 @@ width: 100%; margin-bottom: 10px; } -} -.actionsBar_flexWrapper { - display: flex; - flex-direction: row; - align-items: center; + &.transparent { + background-color: transparent; + border: none; + backdrop-filter: none; + --webkit-backdrop-filter: none; + } - height: 100%; - transition: all 200ms ease-in-out; - - > div { - margin: 0 5px; - display: flex; - flex-direction: column; - align-items: center; + &.spaced { + .wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } } } diff --git a/packages/app/src/components/AddableSelectList/index.jsx b/packages/app/src/components/AddableSelectList/index.jsx new file mode 100644 index 00000000..4f664c4f --- /dev/null +++ b/packages/app/src/components/AddableSelectList/index.jsx @@ -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: , + complete: , +} + +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 + } + + return
+
+
+ +
+
+ { + return
{statusRecord[status]}
+ }} + onRefresh={fetchData} + > + + Done +
+ ]} + events={{ + onDone: (ctx, keys) => props.handleDone(keys, keys.map((key) => findData(key))), + }} + disabledKeys={props.excludedIds} + renderItem={(item) => { + return
+
+

{item.label}

+
+ }} + /> + +
+} + +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 + + } + title={item.label} + /> + + +} + +//@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
+
+ { + return this.onClickItem(item)} + onDelete={() => { this.onDeleteItem(item) }} + /> + }} + /> +
+
+
+ } + shape="round" + onClick={this.onClickAdd} + > + Add + +
+
+
+ } +} \ No newline at end of file diff --git a/packages/app/src/components/AddableSelectList/index.less b/packages/app/src/components/AddableSelectList/index.less new file mode 100644 index 00000000..5d99bd48 --- /dev/null +++ b/packages/app/src/components/AddableSelectList/index.less @@ -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; + } +} \ No newline at end of file diff --git a/packages/app/src/components/AdminTools/UserDataManager/index.jsx b/packages/app/src/components/AdminTools/UserDataManager/index.jsx new file mode 100644 index 00000000..8371df59 --- /dev/null +++ b/packages/app/src/components/AdminTools/UserDataManager/index.jsx @@ -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 ( +
+ {header ? header : null} + +
+ ) +} + +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 ( +
+
+
+

+ Account information +

+ + Username +
+ } + component={antd.Input} + props={{ + placeholder: "Username", + disabled: true, + }} + handleChange={this.handleChange} + /> + + Name +
+ } + component={antd.Input} + props={{ + placeholder: "Your full name", + }} + handleChange={this.handleChange} + /> + + Email +
+ } + component={antd.Input} + props={{ + placeholder: "Your email address", + type: "email", + }} + handleChange={this.handleChange} + /> +
+
+ +
+ {this.state.changes.length} + {(t) => t("Changes")} + +
+
+ + + {(t) => t("Save")} + + +
+
+
+ ) + } +} diff --git a/packages/app/src/pages/account/components/editor/index.less b/packages/app/src/components/AdminTools/UserDataManager/index.less similarity index 95% rename from packages/app/src/pages/account/components/editor/index.less rename to packages/app/src/components/AdminTools/UserDataManager/index.less index 9f83c10c..08713209 100644 --- a/packages/app/src/pages/account/components/editor/index.less +++ b/packages/app/src/components/AdminTools/UserDataManager/index.less @@ -23,6 +23,7 @@ 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; diff --git a/packages/app/src/components/AdminTools/UserRolesManager/index.jsx b/packages/app/src/components/AdminTools/UserRolesManager/index.jsx new file mode 100644 index 00000000..5e411ccc --- /dev/null +++ b/packages/app/src/components/AdminTools/UserRolesManager/index.jsx @@ -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
+

+ {item.username} +

+
+ {this.state.roles.map((role) => { + return this.handleRoleChange(item._id, role.name, to.target.checked)} + > + {role.name} + + })} +
+
+ } + + render() { + const { users } = this.state + + if (!users) { + return + } + + return
+ {users.map((data) => { + return this.renderItem(data) + })} + + +
+ } onClick={() => this.handleSubmit()}> + Submit + +
+
+
+ } +} \ No newline at end of file diff --git a/packages/app/src/components/AdminTools/UserRolesManager/index.less b/packages/app/src/components/AdminTools/UserRolesManager/index.less new file mode 100644 index 00000000..bd373174 --- /dev/null +++ b/packages/app/src/components/AdminTools/UserRolesManager/index.less @@ -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; + } +} \ No newline at end of file diff --git a/packages/app/src/components/AdminTools/index.js b/packages/app/src/components/AdminTools/index.js new file mode 100644 index 00000000..a570f549 --- /dev/null +++ b/packages/app/src/components/AdminTools/index.js @@ -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() + } + }) + }) + } +} \ No newline at end of file diff --git a/packages/app/src/components/AppSearcher/index.jsx b/packages/app/src/components/AppSearcher/index.jsx index 79658f54..41a6eb39 100644 --- a/packages/app/src/components/AppSearcher/index.jsx +++ b/packages/app/src/components/AppSearcher/index.jsx @@ -32,7 +32,6 @@ export default class AppSearcher extends React.Component { let results = [] // get results - console.log(value) results.push({ id: value, title: value }) // storage results diff --git a/packages/app/src/components/AppSearcher/index.less b/packages/app/src/components/AppSearcher/index.less index 0f8de6c9..45519141 100644 --- a/packages/app/src/components/AppSearcher/index.less +++ b/packages/app/src/components/AppSearcher/index.less @@ -2,6 +2,7 @@ .search_bar { user-select: none; + --webkit-user-select: none; height: fit-content; border: 0; @@ -9,16 +10,16 @@ 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; + 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; + align-items: center; + justify-content: center; + height: fit-content; } .ant-input-group-addon { @@ -27,7 +28,7 @@ } .ant-btn { - background-color: var(--background-color-primary)!important; + background-color: var(--background-color-primary) !important; border: 0 !important; } } diff --git a/packages/app/src/components/Crash/index.jsx b/packages/app/src/components/Crash/index.jsx new file mode 100644 index 00000000..776c6118 --- /dev/null +++ b/packages/app/src/components/Crash/index.jsx @@ -0,0 +1,23 @@ +import React from "react" +import { Result, Button } from "antd" + +export default (props) => { + const { crash } = props + + return
+ window.location.reload()}> + Reload app + + ]} + > +
+ {crash.error} +
+
+
+} \ No newline at end of file diff --git a/packages/app/src/components/DraggableDrawer/helpers.js b/packages/app/src/components/DraggableDrawer/helpers.js new file mode 100644 index 00000000..22b65f42 --- /dev/null +++ b/packages/app/src/components/DraggableDrawer/helpers.js @@ -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"; +} \ No newline at end of file diff --git a/packages/app/src/components/DraggableDrawer/index.jsx b/packages/app/src/components/DraggableDrawer/index.jsx new file mode 100644 index 00000000..3f3a8e01 --- /dev/null +++ b/packages/app/src/components/DraggableDrawer/index.jsx @@ -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( + + {({ translate }) => { + return ( +
+ + +
+ {this.props.children} +
+
+ ) + }} +
, + 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%; +` \ No newline at end of file diff --git a/packages/app/src/components/ImageUploader/index.jsx b/packages/app/src/components/ImageUploader/index.jsx new file mode 100644 index 00000000..663ac5a1 --- /dev/null +++ b/packages/app/src/components/ImageUploader/index.jsx @@ -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 = (
+ +
Upload
+
) + + return
+ + {this.state.fileList.length >= 8 ? null : uploadButton} + + + + +
+ } +} \ No newline at end of file diff --git a/packages/app/src/components/ImageViewer/index.jsx b/packages/app/src/components/ImageViewer/index.jsx new file mode 100644 index 00000000..0c9bb35c --- /dev/null +++ b/packages/app/src/components/ImageViewer/index.jsx @@ -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
+ + {props.src.map((image) => { + return { + openViewer(image) + }} + > + { + openViewer() + }} + onError={(e) => { + e.target.src = "/broken-image.svg" + }} + /> + + })} + +
+} + +export default ImageViewer \ No newline at end of file diff --git a/packages/app/src/components/ImageViewer/index.less b/packages/app/src/components/ImageViewer/index.less new file mode 100644 index 00000000..cafc1f60 --- /dev/null +++ b/packages/app/src/components/ImageViewer/index.less @@ -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; + } + } +} diff --git a/packages/app/src/components/Navigation/index.jsx b/packages/app/src/components/Navigation/index.jsx new file mode 100644 index 00000000..5ef45b63 --- /dev/null +++ b/packages/app/src/components/Navigation/index.jsx @@ -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
+

+ {Icons[group.icon] && createIconRender(group.icon)} + + {(t) => t(group.title)} + +

+
+ { + group.children.map((item) => { + return this.renderItem(item) + }) + } +
+
+ }) + } + + renderItem = (item, index) => { + return
this.onClick(item.id)} + className="item" + > +
+ {Icons[item.icon] && createIconRender(item.icon)} +
+
+

+ + {(t) => t(item.title ?? item.id)} + +

+
+
+ } + + render() { + return
+ {this.generateMenus(Items)} +
+ } +} \ No newline at end of file diff --git a/packages/app/src/components/Navigation/index.less b/packages/app/src/components/Navigation/index.less new file mode 100644 index 00000000..a2cd3498 --- /dev/null +++ b/packages/app/src/components/Navigation/index.less @@ -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; + } + } +} \ No newline at end of file diff --git a/packages/app/src/components/PostCard/index.jsx b/packages/app/src/components/PostCard/index.jsx new file mode 100644 index 00000000..8cfa9d78 --- /dev/null +++ b/packages/app/src/components/PostCard/index.jsx @@ -0,0 +1,24 @@ +import React from "react" +import * as antd from "antd" + +import "./index.less" + +export default class PostCard extends React.Component { + render() { + return
+
+
+ +
+
+

+ @{this.props.data.user.username} +

+
+
+
+ {this.props.data.message} +
+
+ } +} \ No newline at end of file diff --git a/packages/app/src/components/PostCard/index.less b/packages/app/src/components/PostCard/index.less new file mode 100644 index 00000000..613d77af --- /dev/null +++ b/packages/app/src/components/PostCard/index.less @@ -0,0 +1,45 @@ +.postCard { + display: inline-flex; + flex-direction: column; + + width: 100%; + max-width: 40vw; + padding: 17px; + + background-color: var(--background-color-accent); + border-radius: 8px; + + .userInfo { + display: inline-flex; + flex-direction: row; + align-items: center; + + margin-bottom: 15px; + + h1 { + margin: 0; + font-family: "DM Mono", monospace; + } + + > div { + margin-right: 10px; + } + + } + + .content { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + + background-color: var(--background-color-primary); + padding: 10px; + border-radius: 8px; + + font-size: 14px; + font-family: "Poppins", sans-serif; + + overflow: hidden; + word-break: break-all; + } +} \ No newline at end of file diff --git a/packages/app/src/components/RenderError/index.jsx b/packages/app/src/components/RenderError/index.jsx index 825d8aef..767e0753 100644 --- a/packages/app/src/components/RenderError/index.jsx +++ b/packages/app/src/components/RenderError/index.jsx @@ -44,7 +44,7 @@ export default (props) => { Go Main diff --git a/packages/app/src/components/RenderWindow/index.less b/packages/app/src/components/RenderWindow/index.less index 0b9ca6bc..921e0306 100644 --- a/packages/app/src/components/RenderWindow/index.less +++ b/packages/app/src/components/RenderWindow/index.less @@ -13,7 +13,9 @@ &.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)); } } @@ -29,21 +31,21 @@ display: flex; flex-direction: row; align-items: center; - justify-content: space-between; + justify-content: space-between; > div { - margin: 0 5px; - line-height: 0; - } + margin: 0 5px; + line-height: 0; + } .title { - margin-left: 20px; + margin-left: 20px; - color: #fff - @topbar_background; + color: #fff - @topbar_background; - font-size: 13px; - font-style: italic; - font-family: "JetBrains Mono", monospace; + font-size: 13px; + font-style: italic; + font-family: "JetBrains Mono", monospace; } .actions { @@ -80,10 +82,12 @@ height: calc(100% - @topbar_height); width: 100%; - overflow: overlay; + overflow: overlay; user-select: text !important; + --webkit-user-select: text !important; > div { user-select: text !important; + --webkit-user-select: text !important; } } diff --git a/packages/app/src/components/SearchButton/index.jsx b/packages/app/src/components/SearchButton/index.jsx new file mode 100644 index 00000000..ab155ce2 --- /dev/null +++ b/packages/app/src/components/SearchButton/index.jsx @@ -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
openSearchBox(true)} + className="searchButton"> + openSearchBox(true)} + onBlur={() => openSearchBox(false)} + /> +
+} \ No newline at end of file diff --git a/packages/app/src/components/SearchButton/index.less b/packages/app/src/components/SearchButton/index.less new file mode 100644 index 00000000..6604101f --- /dev/null +++ b/packages/app/src/components/SearchButton/index.less @@ -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; + } +} diff --git a/packages/app/src/components/SelectableList/index.jsx b/packages/app/src/components/SelectableList/index.jsx index d2ecc24c..ecc118b5 100644 --- a/packages/app/src/components/SelectableList/index.jsx +++ b/packages/app/src/components/SelectableList/index.jsx @@ -1,13 +1,113 @@ import React from "react" -import { Icons } from "components/Icons" -import { List, Button } from "antd" +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() { @@ -18,16 +118,52 @@ export default class SelectableList extends React.Component { } } + 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({ - selectedKeys: [...this.props.items.map((item) => item.key ?? item.id ?? item._id)], + selectionEnabled: true, + selectedKeys: updatedSelectedKeys, }) } } unselectAll = () => { this.setState({ + selectionEnabled: false, selectedKeys: [], }) } @@ -35,12 +171,18 @@ export default class SelectableList extends React.Component { 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 }) } @@ -48,7 +190,6 @@ export default class SelectableList extends React.Component { if (typeof this.props.onDone === "function") { this.props.onDone(this.state.selectedKeys) } - this.unselectAll() } @@ -56,17 +197,37 @@ export default class SelectableList extends React.Component { if (typeof this.props.onDiscard === "function") { this.props.onDiscard(this.state.selectedKeys) } - this.unselectAll() } - componentDidUpdate(prevProps, prevState) { - if (typeof this.props.selectionEnabled !== "undefined") { - if (!Boolean(this.props.selectionEnabled) && this.state.selectedKeys.length > 0) { - this.setState({ - selectedKeys: [], - }) + 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 }) } } @@ -75,31 +236,34 @@ export default class SelectableList extends React.Component { return (
-
- {Array.isArray(this.props.actions) && this.renderProvidedActions()} -
+ getLongPressDelay = () => { + return window.app.settings.get("selection_longPress_timeout") } - isKeySelected = (key) => { - return this.state.selectedKeys.includes(key) - } + renderItems = (data) => { + return data.length > 0 ? data.map((item, index) => { + item.key = item.key ?? item.id ?? item._id - renderItem = (item) => { - if (item.children) { - return
- {item.label} -
- {item.children.map((subItem) => { - return this.renderItem(subItem) - })} + if (item.children && Array.isArray(item.children)) { + return
+

+ {React.isValidElement(item.icon) ? item.icon : Icons[item.icon] && createIconRender(item.icon)} + + {t => t(item.label)} + +

+ +
+ {this.renderItems(item.children)} +
-
- } - - const renderChildren = this.props.renderItem(item) - - const _key = item.key ?? item.id ?? item._id ?? renderChildren.key - - const selectionMethod = ["onClick", "onDoubleClick"].includes(this.props.selectionMethod) ? this.props.selectionMethod : "onClick" - const isSelected = this.isKeySelected(_key) - const isDisabled = renderChildren.props.disabled - const isNotSelectable = renderChildren.props.notSelectable - - let renderProps = { - disabled: isDisabled, - children: renderChildren, - className: classnames("selectableList_item", { - ["selected"]: isSelected && !isNotSelectable, - ["disabled"]: isDisabled && !isNotSelectable, - }), - [selectionMethod]: () => { - if (isDisabled && isNotSelectable) { - return false - } - if (typeof this.props.selectionEnabled !== "undefined") { - if (!Boolean(this.props.selectionEnabled)) { - return false - } - } - - if (isSelected) { - this.unselectKey(_key) - } else { - this.selectKey(_key) - } } - } - if (selectionMethod == "onDoubleClick") { - renderProps.onClick = () => { - if (this.state.selectedKeys.length > 0) { - if (isSelected) { - this.unselectKey(_key) - } - } - } - } + let selected = this.isKeySelected(item.key) - return
+ return + }) : } render() { - const { borderer, grid, header, loadMore, locale, pagination, rowKey, size, split, itemLayout, loading } = this.props - const listProps = { - borderer, - grid, - header, - loadMore, - locale, - pagination, - rowKey, - size, - split, - itemLayout, - loading, + if (!this.props.overrideSelectionEnabled && this.state.selectionEnabled && this.state.selectedKeys.length === 0) { + this.setState({ selectionEnabled: false }) + this.unselectAll() } - if (this.state.selectedKeys.length === 0) { - if (window.isMobile && !this.props.ignoreMobileActions) { - window.app.BottomBarController.clear() - } - } else { - if (window.isMobile && !this.props.ignoreMobileActions) { - window.app.BottomBarController.render(this.renderActions()) - } - } + const isAllSelected = this.isAllSelected() + let items = this.renderItems(this.props.items) - return
- -
- {this.props.ignoreMobileActions && this.renderActions()} + return
+
+ {items}
+ {this.props.items.length > 0 && (this.props.overrideSelectionEnabled || this.state.selectionEnabled) && !this.props.actionsDisabled && + +
+ +
+ {this.props.bulkSelectionAction && +
+ +
} + {Array.isArray(this.props.actions) && this.renderProvidedActions()} +
+ }
} } \ No newline at end of file diff --git a/packages/app/src/components/SelectableList/index.less b/packages/app/src/components/SelectableList/index.less index 0d14d7d1..c2320be6 100644 --- a/packages/app/src/components/SelectableList/index.less +++ b/packages/app/src/components/SelectableList/index.less @@ -2,24 +2,70 @@ @selectableList_item_borderColor_normal: rgba(51, 51, 51, 0.3); .selectableList { - &.selectionEnabled { + .selectableList_content { .selectableList_item { - cursor: pointer; + --ignore-dragger: true; + display: inline-flex; + overflow-x: overlay; - border: rgba(51, 51, 51, 0.3) 1px solid; - border-radius: 8px; - margin-bottom: 12px; + align-items: center; - h1 { - user-select: none; + 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; } - h3 { - user-select: none; + + &.disabled { + opacity: 0.5; + pointer-events: none; + } + + ::-webkit-scrollbar { + position: absolute; + display: none; + + width: 0; + height: 0; + z-index: 0; } } - .selectableList_item:hover { - box-shadow: 2px 2px 8px 0px rgba(51, 51, 51, 0.5); - border: @selectableList_item_borderColor_active 1px solid; + + .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; + } + } } } } @@ -31,90 +77,6 @@ .selectableList_subItems { margin-left: 10px; } - + margin-bottom: 10px; } - -.selectableList_item { - display: inline-flex; - overflow-x: overlay; - - align-items: center; - - 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: translate(10px, 0); - } - - &.disabled { - opacity: 0.5; - pointer-events: none; - } - - ::-webkit-scrollbar { - position: absolute; - display: none; - - width: 0; - height: 0; - z-index: 0; - } -} - -.selectableList_bottomActions_wrapper { - position: sticky; - z-index: 300; - - left: 0; - bottom: 0; - - width: 100%; - - display: flex; - flex-direction: row; - - align-items: center; - justify-content: center; -} - -.selectableList_bottomActions { - width: fit-content; - display: inline-flex; - - align-items: center; - justify-content: center; - - padding: 12px; - - border: 1px solid #e0e0e0; - border-radius: 8px; - - background-color: #0c0c0c15; - backdrop-filter: blur(10px); - - transition: all 200ms ease-in-out; - - > div { - margin: 0 5px; - } - - &.mobile { - background-color: transparent; - backdrop-filter: none; - width: 100%; - padding: 0; - border: none; - } -} diff --git a/packages/app/src/components/ServerStatus/index.jsx b/packages/app/src/components/ServerStatus/index.jsx index 1a627649..b36cdccb 100644 --- a/packages/app/src/components/ServerStatus/index.jsx +++ b/packages/app/src/components/ServerStatus/index.jsx @@ -3,7 +3,7 @@ import * as antd from "antd" import { Icons } from "components/Icons" export default () => { - const [connected, setConnected] = React.useState(window.app.ws.connected ?? false) + const [connected, setConnected] = React.useState(window.app.ws.mainSocketConnected ?? false) window.app.eventBus.on("websocket_connected", (status) => { setConnected(true) @@ -22,12 +22,6 @@ export default () => { } return
-
- {window.app?.api?.origin ?? "unavailable"} -
-
- {window.app?.ws?.io?.uri ?? "unavailable"} -
{connected ? "Connected" : "Disconnected"} diff --git a/packages/app/src/components/Sessions/index.jsx b/packages/app/src/components/Sessions/index.jsx deleted file mode 100644 index 8420bd34..00000000 --- a/packages/app/src/components/Sessions/index.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react" -import * as antd from "antd" -import { Icons } from "components/icons" - -import "./index.less" - -export default class Sessions extends React.Component { - renderSessions = () => { - const data = this.props.sessions - - return data.map((session) => { - const header = ( -
-
- -
-
{session._id}
-
{this.props.current === session.uuid ? Current : ""}
-
- ) - - const renderDate = () => { - const dateNumber = Number(session.date) - - if (dateNumber) { - return new Date(dateNumber).toString() - } - return session.date - } - - return ( - -
- {session.allowRegenerate && ( -
- - This token can be regenerated -
- )} -
- - {renderDate()} -
-
- - {session.location} -
-
- - {session.geo} -
-
-
- ) - }) - } - - render() { - if (Array.isArray(this.props.sessions)) { - return ( -
-

- All Sessions -

- - {this.renderSessions()} - -
- ) - } - - return
- } -} diff --git a/packages/app/src/components/Sessions/index.less b/packages/app/src/components/Sessions/index.less deleted file mode 100644 index 2bc59de4..00000000 --- a/packages/app/src/components/Sessions/index.less +++ /dev/null @@ -1,35 +0,0 @@ -.sessions_wrapper { - .ant-collapse-borderless{ - background-color: transparent!important; - } -} - -.session_entry { - display: flex; - flex-direction: column; - - background: transparent; - margin-bottom: 10px; - padding: 10px; - - border: 1px solid #ccc!important; - border-radius: 12px!important; - - .session_entry_info{ - > div { - padding: 4px 40px; - } - } - .ant-collapse-header { - display: flex; - align-items: center; - } -} - -.session_header{ - display: flex; - flex-direction: row; - > div { - padding: 0 10px; - } -} \ No newline at end of file diff --git a/packages/app/src/components/Settings/index.jsx b/packages/app/src/components/Settings/index.jsx index 3ec20d53..1ea099a8 100644 --- a/packages/app/src/components/Settings/index.jsx +++ b/packages/app/src/components/Settings/index.jsx @@ -1,10 +1,11 @@ import React from "react" -import { Icons } from "components/icons" -import { SliderPicker } from "react-color" import * as antd from "antd" -import config from "config" +import { SliderPicker } from "react-color" +import { Translation } from "react-i18next" -import settingList from "schemas/settingsList.json" +import config from "config" +import { Icons } from "components/Icons" +import settingList from "schemas/settings" import groupsDecorator from "schemas/settingsGroupsDecorator.json" import { AboutApp } from ".." @@ -24,37 +25,36 @@ const ItemTypes = { export default class SettingsMenu extends React.Component { state = { - settings: window.app.configuration.settings.get() ?? {}, + settings: window.app.settings.get() ?? {}, } - handleEvent = (event, item, to) => { - const id = item.id - - if (typeof id === "undefined") { - console.error("SettingsMenu: Cannot update, item has no id") + handleUpdate = (item, update) => { + if (typeof item.id === "undefined") { + console.error("[Settings] Cannot handle update, item has no id") return false } - const currentValue = window.app.configuration.settings.get(id) ?? null + const currentValue = window.app.settings.get(item.id) - // by default we set the opposite value to the current value - if (typeof to === "undefined") { - to = !currentValue - } - - if (typeof item.updateValueKey === "string") { - to = { [item.updateValueKey]: to } + if (typeof update === "undefined") { + update = !currentValue } if (typeof item.emitEvent === "string") { - window.app.eventBus.emit(item.emitEvent, { event, to }) + let emissionPayload = update + + if (typeof item.emissionValueUpdate === "function") { + emissionPayload = item.emissionValueUpdate(emissionPayload) + } + + window.app.eventBus.emit(item.emitEvent, emissionPayload) } if (!item.noStorage) { - window.app.configuration.settings.change(id, to) + window.app.settings.set(item.id, update) } - this.setState({ settings: { ...this.state.settings, [id]: to } }) + this.setState({ settings: { ...this.state.settings, [item.id]: update } }) } renderItem = (item) => { @@ -77,18 +77,24 @@ export default class SettingsMenu extends React.Component { item.props.onChange = (color) => { item.props.color = color.hex } - item.props.onChangeComplete = (color, event) => { - this.handleEvent(event, item, color.hex) + item.props.onChangeComplete = (color) => { + this.handleUpdate(item, color.hex) } break } case "switch": { item.props.checked = this.state.settings[item.id] - item.props.onClick = (event) => this.handleEvent(event, item) + item.props.onClick = (event) => this.handleUpdate(item, event) break } case "select": { - item.props.onChange = (event) => this.handleEvent(event, item) + item.props.onChange = (value) => this.handleUpdate(item, value) + item.props.defaultValue = this.state.settings[item.id] + break + } + case "slider":{ + item.props.defaultValue = this.state.settings[item.id] + item.props.onAfterChange = (value) => this.handleUpdate(item, value) break } default: { @@ -96,20 +102,29 @@ export default class SettingsMenu extends React.Component { item.props.children = item.title ?? item.id } item.props.value = this.state.settings[item.id] - item.props.onClick = (event) => this.handleEvent(event, item) + item.props.onClick = (event) => this.handleUpdate(item, event) break } } + // TODO: Support async children + // if (typeof item.children === "function") { + + // } + return (

{Icons[item.icon] ? React.createElement(Icons[item.icon]) : null} - {item.title ?? item.id} + { + t => t(item.title ?? item.id) + }

-

{item.description}

+

{ + t => t(item.description) + }

{item.experimental && Experimental } @@ -130,7 +145,9 @@ export default class SettingsMenu extends React.Component {

{fromDecoratorIcon ? React.createElement(Icons[fromDecoratorIcon]) : null} - {fromDecoratorTitle ?? key} + { + t => t(fromDecoratorTitle ?? key) + }

{group.map((item) => this.renderItem(item))} @@ -166,7 +183,7 @@ export default class SettingsMenu extends React.Component {
{config.app?.siteName}
- v{window.__evite?.projectVersion} + v{window.app.version}
@@ -178,7 +195,9 @@ export default class SettingsMenu extends React.Component {
AboutApp.openModal()}> - About + + {t => t("about")} +
diff --git a/packages/app/src/components/Settings/index.less b/packages/app/src/components/Settings/index.less index 62d6a402..c835deb8 100644 --- a/packages/app/src/components/Settings/index.less +++ b/packages/app/src/components/Settings/index.less @@ -44,6 +44,7 @@ } .component { + --ignore-dragger: true; padding: 0 20px; } } diff --git a/packages/app/src/components/Skeleton/index.jsx b/packages/app/src/components/Skeleton/index.jsx new file mode 100644 index 00000000..3b720728 --- /dev/null +++ b/packages/app/src/components/Skeleton/index.jsx @@ -0,0 +1,15 @@ +import React from "react" +import { Skeleton } from "antd" +import { LoadingOutlined } from "@ant-design/icons" + +import "./index.less" + +export default () => { + return
+
+ +

Loading...

+
+ +
+} \ No newline at end of file diff --git a/packages/app/src/components/Skeleton/index.less b/packages/app/src/components/Skeleton/index.less new file mode 100644 index 00000000..87f3cea4 --- /dev/null +++ b/packages/app/src/components/Skeleton/index.less @@ -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; +} diff --git a/packages/app/src/components/StepsForm/index.jsx b/packages/app/src/components/StepsForm/index.jsx new file mode 100644 index 00000000..4bde7439 --- /dev/null +++ b/packages/app/src/components/StepsForm/index.jsx @@ -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 () =>
+ Error +
+ } + }, { + fallback:
Loading...
, + }) + } + + 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 ( +
+
+ + {steps.map(item => ( + + ))} + + +
+
+

{current.icon && createIconRender(current.icon)} + + {t => t(current.title)} + +

+ + + {t => t(current.required ? "Required" : "Optional")} + + +
+ {current.description &&
+ + {t => t(current.description)} + +
} + {this.state.renderStep} +
+
+ + {this.state.submittingError && ( +
+ + {t => t(String(this.state.submittingError))} + +
+ )} + + + {this.state.step > 0 && ( + this.prev()}> + + + {t => t("Previous")} + + + )} + {this.state.step < steps.length - 1 && ( + this.next()}> + + + {t => t("Next")} + + + )} + {this.state.step === steps.length - 1 && ( + + {this.state.submitting && } + + {t => t("Done")} + + + )} + +
+ ) + } +} \ No newline at end of file diff --git a/packages/app/src/components/StepsForm/index.less b/packages/app/src/components/StepsForm/index.less new file mode 100644 index 00000000..1a0c1723 --- /dev/null +++ b/packages/app/src/components/StepsForm/index.less @@ -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; + } + } +} diff --git a/packages/app/src/components/SwipeItem/index.jsx b/packages/app/src/components/SwipeItem/index.jsx new file mode 100644 index 00000000..4c2890bd --- /dev/null +++ b/packages/app/src/components/SwipeItem/index.jsx @@ -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 ( + { + if (c) { + this.container = c + this.containerWidth = c.getBoundingClientRect().width + this.deleteWithoutConfirmThreshold = this.containerWidth * 0.75 + } + }} + > + + + + + {this.props.children} + + + ) + } +} + +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 \ No newline at end of file diff --git a/packages/app/src/components/SwipeItem/styles.js b/packages/app/src/components/SwipeItem/styles.js new file mode 100644 index 00000000..bd62c4d0 --- /dev/null +++ b/packages/app/src/components/SwipeItem/styles.js @@ -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; + } +` \ No newline at end of file diff --git a/packages/app/src/components/UserRegister/index.jsx b/packages/app/src/components/UserRegister/index.jsx new file mode 100644 index 00000000..a582f827 --- /dev/null +++ b/packages/app/src/components/UserRegister/index.jsx @@ -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
+ { + props.handleUpdate(e.target.value) + }} + /> +
+ }, + }, + { + key: "password", + title: "Step 2", + icon: "Key", + description: "Enter a password for the account", + required: true, + content: (props) => { + return
+ { + props.handleUpdate(e.target.value) + }} + /> +
+ }, + }, + { + key: "email", + title: "Step 3", + icon: "Mail", + description: "Enter a email for the account", + required: true, + content: (props) => { + return
+ { + props.handleUpdate(e.target.value) + }} + /> +
+ }, + }, +] + +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 +} \ No newline at end of file diff --git a/packages/app/src/components/UserRegister/index.less b/packages/app/src/components/UserRegister/index.less new file mode 100644 index 00000000..e69de29b diff --git a/packages/app/src/components/UserSelector/index.jsx b/packages/app/src/components/UserSelector/index.jsx index 0fcdc655..71c2f77b 100644 --- a/packages/app/src/components/UserSelector/index.jsx +++ b/packages/app/src/components/UserSelector/index.jsx @@ -1,7 +1,7 @@ import React from "react" import * as antd from "antd" -import { Icons } from "components/Icons" -import { SelectableList } from "components" +import { Translation } from "react-i18next" +import { SelectableList, Skeleton } from "components" import { debounce } from "lodash" import fuse from "fuse.js" @@ -43,13 +43,9 @@ export default class UserSelector extends React.Component { } renderItem = (item) => { - return
-
- -
-
-

{item.fullName ?? item.username}

-
+ return
+
+

{item.fullName ?? item.username}

} @@ -90,7 +86,7 @@ export default class UserSelector extends React.Component { render() { if (this.state.loading) { - return + return } return
@@ -105,15 +101,21 @@ export default class UserSelector extends React.Component {
- Done +
+ + {t => t("Done")} +
]} - onDone={(keys) => this.props.handleDone(keys)} + events={{ + onDone: (ctx, keys) => this.props.handleDone(keys), + }} />
} diff --git a/packages/app/src/components/UserSelector/index.less b/packages/app/src/components/UserSelector/index.less index fd95cd48..7d714b89 100644 --- a/packages/app/src/components/UserSelector/index.less +++ b/packages/app/src/components/UserSelector/index.less @@ -3,7 +3,7 @@ margin-bottom: 10px; } - .item { + .user { display: flex; flex-direction: row; align-items: center; diff --git a/packages/app/src/components/formGenerator/index.jsx b/packages/app/src/components/formGenerator/index.jsx index af22be99..0ac704f6 100644 --- a/packages/app/src/components/formGenerator/index.jsx +++ b/packages/app/src/components/formGenerator/index.jsx @@ -296,7 +296,7 @@ export default class FormGenerator extends React.Component { if (typeof element.options !== "undefined" && !element.renderItem) { if (!Array.isArray(element.options)) { console.warn( - `Invalid options data type, expecting Array > recived ${typeof element.options}`, + `Invalid options data type, expecting Array > received ${typeof element.options}`, ) return null } diff --git a/packages/app/src/components/index.js b/packages/app/src/components/index.js index b8c551ca..8d3b4a05 100644 --- a/packages/app/src/components/index.js +++ b/packages/app/src/components/index.js @@ -3,8 +3,6 @@ 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 Sessions } from "./Sessions" export { default as ActionsBar } from "./ActionsBar" export { default as SelectableList } from "./SelectableList" export { default as ObjectInspector } from "./ObjectInspector" @@ -12,7 +10,20 @@ 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 ScheduledProgress } from "./ScheduledProgress" +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 PostCard } from "./PostCard" + +export * as AdminTools from "./AdminTools" export * as AboutApp from "./AboutApp" export * as Window from "./RenderWindow" \ No newline at end of file diff --git a/packages/app/src/extensions/api/index.js b/packages/app/src/extensions/api/index.js index cb8941d6..edf77814 100644 --- a/packages/app/src/extensions/api/index.js +++ b/packages/app/src/extensions/api/index.js @@ -1,39 +1,141 @@ -import config from 'config' -import { Bridge } from "linebridge/client" +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: [ { - mutateContext: { - async initializeDefaultBridge() { - this.apiBridge = await this.createBridge() - this.ws = io(config.ws.address, { transports: ["websocket"] }) + initialization: [ + async (app, main) => { + app.apiBridge = await app.createApiBridge() - this.ws.on("connect", (...context) => { - window.app.eventBus.emit("websocket_connected", ...context) + 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) }) - this.ws.on("disconnect", (...context) => { + 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 }) - this.ws.on("connect_error", (...context) => { + app.WSSockets.main.on("connect_error", (...context) => { window.app.eventBus.emit("websocket_connection_error", ...context) + app.WSInterface.mainSocketConnected = false }) - window.app.ws = this.ws - window.app.api = this.apiBridge - window.app.request = this.apiBridge.endpoints - }, - createBridge: async () => { - const getSessionContext = () => { - const obj = {} - const token = Session.token + window.app.api = app.apiBridge + window.app.ws = app.WSInterface - if (typeof token !== "undefined") { + 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}`, } @@ -42,20 +144,43 @@ export default { 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, - onRequestContext: getSessionContext, - }) - - await bridge.initialize().catch((err) => { - throw { - message: "Failed to connect with API", - description: err.message, - } + 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) + }) + }) + } }, }, ], diff --git a/packages/app/src/extensions/haptics/index.js b/packages/app/src/extensions/haptics/index.js new file mode 100644 index 00000000..f9a6b5b6 --- /dev/null +++ b/packages/app/src/extensions/haptics/index.js @@ -0,0 +1,68 @@ +import Evite from "evite" + +import { Haptics, ImpactStyle } from "@capacitor/haptics" + +// This is a temporal workaround to make the extension work with the new evite extension system. +export default class HapticExtensionV2 extends Evite.Extension { + static id = "hapticsEngine" + + static compatible = ["mobile"] + + static extendsWith = ["SettingsController"] + + statement = { + test: "macarronie", + } + + initialization = [ + async (app, main) => { + console.log(this.statement.test) + } + ] + + debug = { + testVibrate: () => { + + }, + testSelectionStart: () => { + + }, + testSelectionChanged: () => { + + }, + testSelectionEnd: () => { + + }, + } + + public = { + vibrate: async function () { + const enabled = this.extended.SettingsController.get("haptic_feedback") + + if (enabled) { + await Haptics.vibrate() + } + }, + selectionStart: async function () { + const enabled = this.extended.SettingsController.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionStart() + } + }, + selectionChanged: async function () { + const enabled = this.extended.SettingsController.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionChanged() + } + }, + selectionEnd: async function () { + const enabled = this.extended.SettingsController.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionEnd() + } + }, + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/i18n/index.js b/packages/app/src/extensions/i18n/index.js new file mode 100644 index 00000000..41e9c530 --- /dev/null +++ b/packages/app/src/extensions/i18n/index.js @@ -0,0 +1,78 @@ +import config from "config" +import i18n from "i18next" +import { initReactI18next } from "react-i18next" + +export const SUPPORTED_LANGUAGES = config.i18n?.languages ?? {} +export const SUPPORTED_LOCALES = SUPPORTED_LANGUAGES.map((l) => l.locale) +export const DEFAULT_LOCALE = config.i18n?.defaultLocale + +export function extractLocaleFromPath(path = "") { + const [_, maybeLocale] = path.split("/") + return SUPPORTED_LOCALES.includes(maybeLocale) ? maybeLocale : DEFAULT_LOCALE +} + +const messageImports = import.meta.glob("./translations/*.json") + +export const extension = { + key: "i18n", + expose: [ + { + initialization: [ + async (app, main) => { + let locale = app.settingsController.get("language") ?? DEFAULT_LOCALE + + if (!SUPPORTED_LOCALES.includes(locale)) { + locale = DEFAULT_LOCALE + } + + const messages = await app.importLocale(locale) + + i18n + .use(initReactI18next) // passes i18n down to react-i18next + .init({ + // debug: true, + resources: { + [locale]: { translation: messages.default || messages }, + }, + lng: locale, + //fallbackLng: DEFAULT_LOCALE, + interpolation: { + escapeValue: false, // react already safes from xss + }, + }) + + main.eventBus.on("changeLanguage", (locale) => { + app.loadAsyncLanguage(locale) + }) + }, + ], + mutateContext: { + importLocale: async (locale) => { + const [, importLocale] = + Object.entries(messageImports).find(([key]) => + key.includes(`/${locale}.`) + ) || [] + + return importLocale && importLocale() + }, + loadAsyncLanguage: async function (locale) { + locale = locale ?? DEFAULT_LOCALE + + try { + const result = await this.importLocale(locale) + + if (result) { + i18n.addResourceBundle(locale, "translation", result.default || result) + i18n.changeLanguage(locale) + } + } catch (error) { + console.error(error) + } + } + }, + + }, + ], +} + +export default extension \ No newline at end of file diff --git a/packages/app/src/extensions/i18n/translations/en.json b/packages/app/src/extensions/i18n/translations/en.json new file mode 100644 index 00000000..3303008f --- /dev/null +++ b/packages/app/src/extensions/i18n/translations/en.json @@ -0,0 +1,103 @@ +{ + "main_welcome": "Welcome back,", + "assigned_for_you": "Assigned for you", + "no_assigned_workorders": "No assigned workorders", + "new": "New", + "close": "Close", + "done": "Done", + "edit": "Edit", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "import": "Import", + "export": "Export", + "refresh": "Refresh", + "reload": "Reload", + "search": "Search", + "status": "Status", + "type": "Type", + "about": "About", + "current": "Current", + "statistics": "Statistics", + "name": "Name", + "username": "Username", + "email": "Email", + "password": "Password", + "sessions": "Sessions", + "compact_view": "Compact view", + "sign_in": "Sign in", + "sign_out": "Sign out", + "sign_up": "Sign up", + "all_sessions": "All sessions", + "destroy_all_sessions": "Destroy all sessions", + "account_info": "Account info", + "password_confirmation": "Password confirmation", + "new_product": "New product", + "description": "Description", + "describe_something": "Describe something", + "operations": "Operations", + "select_operation": "Select operation", + "select_operations": "Select operations", + "tasks": "Tasks", + "select_task": "Select task", + "select_tasks": "Select tasks", + "add_task": "Add task", + "add_tasks": "Add tasks", + "location": "Location", + "select_location": "Select location", + "select_locations": "Select locations", + "add_location": "Add location", + "add_locations": "Add locations", + "materials": "Materials", + "select_material": "Select material", + "select_materials": "Select materials", + "add_material": "Add material", + "add_materials": "Add materials", + "quantity": "Quantity", + "select_quantity": "Select quantity", + "select_quantities": "Select quantities", + "add_quantity": "Add quantity", + "add_quantities": "Add quantities", + "units": "Units", + "select_unit": "Select unit", + "select_units": "Select units", + "add_unit": "Add unit", + "add_units": "Add units", + "suppliers": "Suppliers", + "select_supplier": "Select supplier", + "select_suppliers": "Select suppliers", + "add_supplier": "Add supplier", + "add_suppliers": "Add suppliers", + "customers": "Customers", + "select_customer": "Select customer", + "select_customers": "Select customers", + "add_customer": "Add customer", + "add_customers": "Add customers", + "employees": "Employees", + "select_employee": "Select employee", + "select_employees": "Select employees", + "add_employee": "Add employee", + "add_employees": "Add employees", + "equipment": "Equipment", + "select_equipment": "Select equipment", + "select": "Select", + "variants": "Variants", + "select_variant": "Select variant", + "select_variants": "Select variants", + + "settins_group_general": "General", + "settings_general_language": "Language", + "settings_general_language_description": "Choose language for using in application.", + "settings_general_sidebarAutoCollapse": "Sidebar auto collapse", + "settings_general_sidebarAutoCollapse_description": "Collapse sidebar when loose focus.", + + "settings_group_aspect": "Aspect", + "settings_aspect_reduceAnimations": "Reduce animations", + "settings_aspect_reduceAnimations_description": "Reduce animation of the application.", + "settings_aspect_darkMode": "Dark mode", + "settings_aspect_darkMode_description": "Enable dark mode for the application.", + "settings_aspect_primaryColor": "Primary color", + "settings_aspect_primaryColor_description": "Change primary color of the application.", + "settings_aspect_resetTheme": "Reset theme", + "settings_aspect_resetTheme_description": "Reset theme to default." +} \ No newline at end of file diff --git a/packages/app/src/extensions/i18n/translations/es.json b/packages/app/src/extensions/i18n/translations/es.json new file mode 100644 index 00000000..d6cfe304 --- /dev/null +++ b/packages/app/src/extensions/i18n/translations/es.json @@ -0,0 +1,214 @@ +{ + "Dashboard": "Inicio", + "main_welcome": "Bienvenido,", + "assigned_for_you": "Asignado para usted", + "no_assigned_workorders": "No hay trabajos asignados", + "Start": "Iniciar", + "End": "Finalizar", + "Stop": "Parar", + "Started": "Iniciado", + "started": "iniciado", + "Ended": "Finalizado", + "ended": "finalizado", + "Expired": "Expirado", + "expired": "expirado", + "Stopped": "Parado", + "stopped": "parado", + "Pending": "Pendiente", + "pending": "pendiente", + "Finished": "Finalizado", + "finished": "terminado", + "Cancelled": "Cancelado", + "cancelled": "cancelado", + "Assigned": "Asignado", + "assigned": "asignado", + "Ready": "Listo", + "ready": "listo", + "No description": "Sin descripción", + "All": "Todos", + "all": "todos", + "or": "o", + "Browse": "Buscar", + "Create new": "Crear nuevo", + "New": "Nuevo", + "Close": "Cerrar", + "Done": "Listo", + "Next": "Siguiente", + "Previous": "Anterior", + "Schedule": "Plazo", + "Edit": "Modificar", + "Save": "Guardar", + "Cancel": "Cancelar", + "Delete": "Eliminar", + "State": "Estado", + "Modify": "Modificar", + "modify": "modificar", + "Notifications": "Notificaciones", + "Notification": "Notificación", + "Haptic": "Vibración", + "Haptic Feedback": "Vibración de respuesta", + "Enable haptic feedback on touch events.": "Habilitar vibración de respuesta cuando exista un evento de toque.", + "Selection press delay": "Retraso de presión de selección", + "Set the delay before the selection trigger is activated.": "Establecer el retraso antes de que el disparador de selección sea activado.", + "Force Mobile Mode": "Forzar modo móvil", + "Force the application to run in mobile mode.": "Forzar la aplicación a ejecutarse en modo móvil.", + "Manage operators": "Administrar operadores", + "Manage users": "Administrar usuarios", + "Manage groups": "Administrar grupos", + "Manage workflows": "Administrar flujos de trabajo", + "Manage roles": "Administrar roles", + "Manage permissions": "Administrar permisos", + "Disable": "Deshabilitar", + "Disabled": "Deshabilitado", + "Discard": "Descartar", + "Unselect all": "Deseleccionar todo", + "Select all": "Seleccionar todo", + "Add commit": "Añadir registro", + "Commit": "Registrar", + "Commits": "Registros", + "Assistant mode": "Modo asistente", + "View finished": "Ver terminados", + "View pending": "Ver pendientes", + "View assigned": "Ver asignados", + "View ready": "Ver listos", + "View cancelled": "Ver cancelados", + "View all": "Ver todos", + "View": "Ver", + "Mark produced quantity": "Marcar cantidad producida", + "Mark remaining amount": "Marcar cantidad restante", + "Quantity": "Cantidad", + "Quantity produced": "Cantidad producida", + "Quantity left": "Cantidad restante", + "Production target": "Objectivo de producción", + "Section": "Sección", + "Sections": "Secciones", + "Workshift": "Turno", + "workshift": "turno", + "Workshifts": "Turnos", + "workshifts": "turnos", + "Operation": "Operación", + "Operations": "Operaciones", + "Stock Target": "Stock objetivo", + "Vault item": "Artículo de bóveda", + "Stock item": "Artículo de stock", + "Vault": "Bóveda", + "Phase": "Fase", + "Variants": "Variantes", + "Variant": "Variante", + "Description": "Descripción", + "Task": "Tarea", + "Tasks": "Tareas", + "Product": "Producto", + "Products": "Productos", + "Operator": "Operador", + "Operators": "Operadores", + "Workload": "Carga de trabajo", + "workload": "carga de trabajo", + "Workloads": "Cargas de trabajo", + "workloads": "cargas de trabajo", + "Workorder": "Orden de trabajo", + "workorder": "orden de trabajo", + "Workorders": "Ordenes de trabajo", + "workorders": "ordenes de trabajo", + "Workpart": "Parte de trabajo", + "workpart": "parte de trabajo", + "Workparts": "Partes de trabajo", + "workparts": "parte de trabajo", + "Payload": "Carga", + "payload": "carga", + "Payloads": "Cargas", + "payloads": "carga", + "Commit all": "Registrar todo", + "Mark quantity": "Marcar cantidad", + "Mark": "Marcar", + "Marked": "Marcado", + "Marked quantity": "Cantidad marcada", + "Marked amount": "Cantidad marcada", + "Marked amount left": "Cantidad restante marcada", + "Marked amount produced": "Cantidad producida marcada", + "Marked amount remaining": "Cantidad restante marcada", + "Marked amount target": "Cantidad objetivo marcada", + "Marked amount stock": "Cantidad stock marcada", + "Notifications Sound": "Sonido de notificación", + "Play a sound when a notification is received.": "Reproducir un sonido cuando se recibe una notificación.", + "Vibration": "Vibración", + "Vibrate the device when a notification is received.": "Vibrar el dispositivo cuando se recibe una notificación.", + "Sound Volume": "Volumen de sonido", + "Set the volume of the sound when a notification is received.": "Establecer el volumen del sonido cuando se recibe una notificación.", + "Workorder Notifications": "Notificaciones de orden de trabajo", + "Display in-app notifications for workorders updates.": "Mostrar notificaciones para las actualizaciones de las ordenes de trabajo.", + "Accounts": "Cuentas", + "Import": "Importar", + "Export": "Exportar", + "Refresh": "Actualizar", + "Reload": "Recargar", + "Required": "Requerido", + "Optional": "Opcional", + "Search": "Buscar", + "Status": "Estado", + "Type": "Tipo", + "About": "Acerca de", + "Current": "Actual", + "Statistics": "Estadísticas", + "Name": "Nombre", + "Users": "Usuarios", + "Username": "Nombre de usuario", + "Settings": "Configuración", + "Email": "Correo electrónico", + "Password": "Contraseña", + "Sessions": "Sesiones", + "Compact view": "Vista compacta", + "Add to catalog": "Añadir al catálogo", + "Fabric": "Fabric", + "Press and hold for 2 seconds to toogle running": "Pulse y mantenga pulsado durante 2 segundos para alternar el funcionamiento", + "Production quantity already has been reached": "La cantidad de producción ya ha sido alcanzada", + "Production quantity is not enough": "La cantidad de producción no es suficiente", + "Are you sure you want to commit for this workpart?": "¿Está seguro de que desea consolidar esta parte de trabajo?", + "Are you sure you want to commit all quantity left?": "¿Está seguro de que desea consolidar toda la cantidad restante?", + "This will commit all quantity left and finish the production for this workload.": "Esto consolidará toda la cantidad restante y terminará la producción para esta carga de trabajo.", + "Are you sure you want to commit for this workorder?": "¿Está seguro de que desea consolidar esta orden de trabajo?", + "Enter the name or a reference for the workorder.": "Introduzca el nombre o una referencia para la orden de trabajo.", + "Select the section where the workorder will be deployed.": "Seleccione la sección donde se desplegará la orden de trabajo.", + "Select the schedule for the workorder.": "Seleccione el plazo para la orden de trabajo.", + "Assign the operators for the workorder.": "Asigne los operadores para la orden de trabajo.", + "Define the payloads for the workorder.": "Defina las cargas para la orden de trabajo.", + "Define the workloads for the workorder.": "Defina las cargas de trabajo para la orden de trabajo.", + "Leaving process running on background, dont forget to stop it when you are done": "Dejando el proceso en ejecución en segundo plano, no olvide detenerlo cuando haya terminado", + "Task remains opened, dont forget to stop it when you are done": "La tarea permanece abierta, no olvide detenerla cuando haya terminado", + "Select a option": "Seleccione una opción", + "Set the quantity produced": "Marque la cantidad producida", + "Experimental": "Experimental", + "New workorder assigned": "Nueva orden de trabajo asignada", + "Check the new list of workorder": "Compruebe la nueva lista de ordenes de trabajo", + "Do you want to delete these items?": "¿Desea eliminar estos elementos?", + "This action cannot be undone, and will permanently delete the selected items.": "Esta acción no se puede deshacer, y eliminará permanentemente los elementos seleccionados.", + "Assigned operators": "Operadores asignados", + "Assigments": "Asignaciones", + "Working Tasks": "Tareas en ejecución", + "You are not working on any task": "No estás trabajando en ninguna tarea", + "Update": "Actualizar", + "Update status": "Actualizar estado", + "Archived": "Archivado", + "archived": "archivado", + "Archive": "Archivar", + "archive": "archivar", + "General": "General", + "Sidebar": "Barra lateral", + "Aspect": "Aspecto", + "Language": "Idioma", + "Choose a language for the application": "Elige un idioma para la aplicación.", + "Edit Sidebar": "Editar barra lateral", + "Auto Collapse": "Auto colapsar", + "Collapse the sidebar when loose focus": "Colapsar la barra lateral cuando pierda el foco.", + "Reduce animation": "Reducir animación", + "Primary color": "Color primario", + "Change primary color of the application.": "Cambia el color primario de la aplicación.", + "Dark mode": "Modo oscuro", + "Images": "Imágenes", + "Add others": "Añadir otros", + "Export tokens": "Exportar bonos de trabajo", + "Description of the task. It should be a general description of the product. Do not include information that may vary. e.g. 'The product is a white shirt with a elastic red collar, size M'": "Descripción de la tarea. Debe ser una descripción general del producto. No incluya información que pueda variar. Por ejemplo, 'El producto es una camisa blanca con un collar de color rojo, tamaño M'", + "Define variants for this item. Only the types of variations that may exist of a product should be included. e.g. Size, Color, Material, etc.": "Defina las variantes para este artículo. Sólo deben incluirse los tipos de variaciones que pueden existir de un producto. Por ejemplo, Tamaño, Color, Material, etc.", + "Append some images to describe this item.": "Agregue algunas imágenes para describir este artículo." + +} \ No newline at end of file diff --git a/packages/app/src/extensions/index.js b/packages/app/src/extensions/index.js index 674d2ef9..bee6d830 100644 --- a/packages/app/src/extensions/index.js +++ b/packages/app/src/extensions/index.js @@ -1,5 +1,9 @@ -export * as Render from './render' -export * as Splash from './splash' -export * as Sound from './sound' -export * as Theme from './theme' -export { default as API } from './api' \ No newline at end of file +export * as Render from "./render" +export * as Splash from "./splash" +export * as Sound from "./sound" +export * as Theme from "./theme" +export * as i18n from "./i18n" +export * as Notifications from "./notifications" + +export { default as SettingsController } from "./settings" +export { default as API } from "./api" \ No newline at end of file diff --git a/packages/app/src/extensions/notifications/index.jsx b/packages/app/src/extensions/notifications/index.jsx new file mode 100644 index 00000000..e1dde115 --- /dev/null +++ b/packages/app/src/extensions/notifications/index.jsx @@ -0,0 +1,86 @@ +import React from "react" +import { notification as Notf } from "antd" +import { Icons } from "components/Icons" +import { Translation } from "react-i18next" +import { Haptics, ImpactStyle } from "@capacitor/haptics" + +class NotificationController { + getSoundVolume = () => { + return (window.app.settings.get("notifications_sound_volume") ?? 50) / 100 + } + + new = (notification, options = {}) => { + this.notify(notification, options) + this.playHaptic(options) + this.playAudio(options) + } + + notify = (notification, options = {}) => { + if (typeof notification === "string") { + notification = { + title: "New notification", + description: notification + } + } + + Notf.open({ + message: + {(t) => t(notification.title)} + , + description: + {(t) => t(notification.description)} + , + duration: notification.duration ?? 4, + icon: React.isValidElement(notification.icon) ? notification.icon : (Icons[notification.icon] ?? ), + }) + } + + playHaptic = async (options = {}) => { + const vibrationEnabled = options.vibrationEnabled ?? window.app.settings.get("notifications_vibrate") + + if (vibrationEnabled) { + await Haptics.vibrate() + } + } + + playAudio = (options = {}) => { + const soundEnabled = options.soundEnabled ?? window.app.settings.get("notifications_sound") + const soundVolume = options.soundVolume ? options.soundVolume / 100 : this.getSoundVolume() + + if (soundEnabled) { + window.app.SoundEngine.play("notification", { + volume: soundVolume, + }) + } + } +} + +const extension = { + key: "notification", + expose: [ + { + initialization: [ + async (app, main) => { + app.NotificationController = new NotificationController() + + main.eventBus.on("changeNotificationsSoundVolume", (value) => { + app.NotificationController.playAudio({ soundVolume: value }) + }) + main.eventBus.on("changeNotificationsVibrate", (value) => { + app.NotificationController.playHaptic({ + vibrationEnabled: value, + }) + }) + main.setToWindowContext("notifications", app.NotificationController) + }, + ], + }, + ], +} + +export { + extension, + NotificationController, +} + +export default extension \ No newline at end of file diff --git a/packages/app/src/extensions/render/index.jsx b/packages/app/src/extensions/render/index.jsx index 6fdac022..0ad09810 100644 --- a/packages/app/src/extensions/render/index.jsx +++ b/packages/app/src/extensions/render/index.jsx @@ -1,9 +1,10 @@ import React from "react" -import loadable from "@loadable/component" +import { EvitePureComponent } from "evite" import routes from "virtual:generated-pages" import progressBar from "nprogress" -import NotFound from "./statics/404" +import NotFoundRender from "./statics/404" +import CrashRender from "./statics/crash" export const ConnectWithApp = (component) => { return window.app.bindContexts(component) @@ -35,67 +36,100 @@ export function GetRoutesComponentMap() { }, {}) } -export class RouteRender extends React.Component { +// class PageStatement { +// constructor() { +// this.state = {} + +// } + +// getProxy() { + +// } +// } + +export class RouteRender extends EvitePureComponent { state = { + renderInitialization: true, + renderComponent: null, + renderError: null, + //pageStatement: new PageStatement(), routes: GetRoutesComponentMap() ?? {}, - error: null, + crash: null, } - lastLocation = null + handleBusEvents = { + "render_initialization": () => { + this.setState({ renderInitialization: true }) + }, + "render_initialization_done": () => { + this.setState({ renderInitialization: false }) + }, + "crash": (message, error) => { + this.setState({ crash: { message, error } }) + }, + "locationChange": (event) => { + this.loadRender() + }, + } componentDidMount() { - window.app.eventBus.on("locationChange", (event) => { - console.debug("[App] LocationChange, forcing update render...") + this._ismounted = true + this._loadBusEvents() + this.loadRender() + } - // render controller needs an better method for update render, this is a temporary solution - // FIXME: this event is called multiple times. we need to debug them methods - if (typeof this.forceUpdate === "function") { - this.forceUpdate() - } - }) + componentWillUnmount() { + this._ismounted = false + this._unloadBusEvents() + } + + loadRender = (path) => { + if (!this._ismounted) { + console.warn("RouteRender is not mounted, skipping render load") + return false + } + + let componentModule = this.state.routes[path ?? this.props.path ?? window.location.pathname] ?? this.props.staticRenders?.NotFound ?? NotFoundRender + + // TODO: in a future use, we can use `pageStatement` class for managing statement + window.app.pageStatement = Object.freeze(componentModule.pageStatement) ?? Object.freeze({}) + + return this.setState({ renderComponent: componentModule }) } componentDidCatch(info, stack) { - this.setState({ error: { info, stack } }) + this.setState({ renderError: { info, stack } }) } - // shouldComponentUpdate(nextProps, nextState) { - // if (this.lastLocation.pathname !== window.location.pathname) { - // return true - // } - // return false - // } - render() { - this.lastLocation = window.location + if (this.state.crash) { + const StaticCrashRender = this.props.staticRenders?.Crash ?? CrashRender - let path = this.props.path ?? window.location.pathname - let componentModule = this.state.routes[path] ?? this.props.staticRenders.NotFound ?? NotFound - - console.debug(`[RouteRender] Rendering ${path}`) - - if (this.state.error) { - if (this.props.staticRenders?.RenderError) { - return React.createElement(this.props.staticRenders?.RenderError, { error: this.state.error }) - } - - return JSON.stringify(this.state.error) + return } - return React.createElement(ConnectWithApp(componentModule), this.props) + if (this.state.renderError) { + if (this.props.staticRenders?.RenderError) { + return React.createElement(this.props.staticRenders?.RenderError, { error: this.state.renderError }) + } + + return JSON.stringify(this.state.renderError) + } + + if (this.state.renderInitialization) { + const StaticInitializationRender = this.props.staticRenders?.initialization ?? null + + return + } + + if (!this.state.renderComponent) { + return null + } + + return React.createElement(ConnectWithApp(this.state.renderComponent), this.props) } } -export const LazyRouteRender = (props) => { - const component = loadable(async () => { - // TODO: Support evite async component initializations - - return RouteRender - }) - - return React.createElement(component) -} - export const extension = { key: "customRender", expose: [ diff --git a/packages/app/src/extensions/render/statics/crash/index.jsx b/packages/app/src/extensions/render/statics/crash/index.jsx new file mode 100644 index 00000000..dd35336f --- /dev/null +++ b/packages/app/src/extensions/render/statics/crash/index.jsx @@ -0,0 +1,15 @@ +import React from "react" +import "./index.less" + +export default (props) => { + return
+
+

Crash

+
+

{props.crash.message}

+
{props.crash.error}
+
+ +
+
+} \ No newline at end of file diff --git a/packages/app/src/extensions/render/statics/crash/index.less b/packages/app/src/extensions/render/statics/crash/index.less new file mode 100644 index 00000000..dd65293b --- /dev/null +++ b/packages/app/src/extensions/render/statics/crash/index.less @@ -0,0 +1,27 @@ +.app_crash { + position: absolute; + z-index: 9999; + + top: 0; + right: 0; + + width: 100vw; + height: 100vh; + + display: flex; + flex-direction: column; + + justify-content: center; + align-items: center; + + .header { + display: flex; + text-align: center; + flex-direction: column; + justify-content: center; + svg { + width: 100px; + height: 100px; + } + } +} \ No newline at end of file diff --git a/packages/app/src/extensions/settings/index.js b/packages/app/src/extensions/settings/index.js new file mode 100644 index 00000000..349f9b0d --- /dev/null +++ b/packages/app/src/extensions/settings/index.js @@ -0,0 +1,66 @@ +import store from "store" +import defaultSettings from "schemas/defaultSettings.json" + +class SettingsController { + constructor() { + this.storeKey = "app_settings" + this.settings = store.get(this.storeKey) ?? {} + + this._setDefaultUndefined() + } + + _setDefaultUndefined = () => { + Object.keys(defaultSettings).forEach((key) => { + const value = defaultSettings[key] + + // Only set default if value is undefined + if (typeof this.settings[key] === "undefined") { + this.settings[key] = value + } + }) + } + + defaults = (key) => { + if (typeof key === "undefined") { + return defaultSettings + } + + return defaultSettings[key] + } + + is = (key, value) => { + return this.settings[key] === value + } + + set = (key, value) => { + this.settings[key] = value + store.set(this.storeKey, this.settings) + + window.app.eventBus.emit("setting_update", { key, value }) + + return this.settings + } + + get = (key) => { + if (typeof key === "undefined") { + return this.settings + } + + return this.settings[key] + } +} + +export default { + key: "settings", + expose: [ + { + initialization: [ + (app, main) => { + app.settingsController = new SettingsController() + window.app.settings = app.settingsController + } + ] + }, + ] + +} \ No newline at end of file diff --git a/packages/app/src/extensions/sound/index.js b/packages/app/src/extensions/sound/index.js index 3f7ced6a..bc950e0a 100644 --- a/packages/app/src/extensions/sound/index.js +++ b/packages/app/src/extensions/sound/index.js @@ -17,19 +17,22 @@ export class SoundEngine { Object.keys(soundPack).forEach((key) => { const src = soundPack[key] - soundPack[key] = new Howl({ - src: [src] + soundPack[key] = (options) => new Howl({ + volume: window.app.settings.get("generalAudioVolume") ?? 0.5, + ...options, + src: [src], }) }) return soundPack } - play = (name) => { + play = (name, options) => { if (this.sounds[name]) { - this.sounds[name].play() + return this.sounds[name](options).play() } else { console.error(`Sound ${name} not found.`) + return false } } } @@ -41,6 +44,7 @@ export const extension = { initialization: [ async (app, main) => { app.SoundEngine = new SoundEngine() + main.setToWindowContext("SoundEngine", app.SoundEngine) await app.SoundEngine.initialize() } ] diff --git a/packages/app/src/extensions/splash/index.less b/packages/app/src/extensions/splash/index.less index 39c6b7d2..d6d2e6a3 100644 --- a/packages/app/src/extensions/splash/index.less +++ b/packages/app/src/extensions/splash/index.less @@ -1,8 +1,9 @@ .splash_wrapper { overflow: hidden; - background-color: rgba(240, 242, 245, 0.8); + //background-color: rgba(240, 242, 245, 0.8); backdrop-filter: blur(10px); + --webkit-backdrop-filter: blur(10px); width: 100%; height: 100%; diff --git a/packages/app/src/extensions/theme/index.jsx b/packages/app/src/extensions/theme/index.jsx index fa94494d..92401335 100644 --- a/packages/app/src/extensions/theme/index.jsx +++ b/packages/app/src/extensions/theme/index.jsx @@ -138,21 +138,16 @@ export const extension = { async (app, main) => { app.ThemeController = new ThemeController() - main.eventBus.on("darkMode", (payload) => { - if (payload.to) { + main.eventBus.on("darkMode", (value) => { + if (value) { app.ThemeController.applyVariant("dark") } else { app.ThemeController.applyVariant("light") } }) - main.eventBus.on("modifyTheme", (payload) => { - if (payload.to) { - app.ThemeController.update(payload.to) - app.ThemeController.setModifications(app.ThemeController.mutation) - } else { - app.ThemeController.update(payload) - app.ThemeController.setModifications(app.ThemeController.mutation) - } + main.eventBus.on("modifyTheme", (value) => { + app.ThemeController.update(value) + app.ThemeController.setModifications(app.ThemeController.mutation) }) main.eventBus.on("resetTheme", () => { app.ThemeController.resetDefault() diff --git a/packages/app/src/i18n/index.js b/packages/app/src/i18n/index.js deleted file mode 100644 index c5fde276..00000000 --- a/packages/app/src/i18n/index.js +++ /dev/null @@ -1,57 +0,0 @@ -import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './locales' -import i18n from 'i18next' -import { initReactI18next } from 'react-i18next' - -export { - DEFAULT_LOCALE, - SUPPORTED_LOCALES, - SUPPORTED_LANGUAGES, - extractLocaleFromPath, -} from './locales' - -// This is a dynamic import so not all languages are bundled in frontend. -// For YAML format, install `@rollup/plugin-yaml`. -const messageImports = import.meta.glob('./translations/*.json') - -function importLocale(locale) { - const [, importLocale] = - Object.entries(messageImports).find(([key]) => - key.includes(`/${locale}.`) - ) || [] - - return importLocale && importLocale() -} - -export async function loadAsyncLanguage(i18n, locale = DEFAULT_LOCALE) { - try { - const result = await importLocale(locale) - if (result) { - i18n.addResourceBundle(locale, 'translation', result.default || result) - i18n.changeLanguage(locale) - } - } catch (error) { - console.error(error) - } -} - -export async function installI18n(locale = '') { - locale = SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE - const messages = await importLocale(locale) - - i18n - .use(initReactI18next) // passes i18n down to react-i18next - .init({ - // debug: true, - resources: { - // @ts-ignore - [locale]: { translation: messages.default || messages }, - }, - lng: locale, - fallbackLng: DEFAULT_LOCALE, - interpolation: { - escapeValue: false, // react already safes from xss - }, - }) -} - -export default i18n \ No newline at end of file diff --git a/packages/app/src/i18n/locales.js b/packages/app/src/i18n/locales.js deleted file mode 100644 index 6b6a7e6c..00000000 --- a/packages/app/src/i18n/locales.js +++ /dev/null @@ -1,18 +0,0 @@ -export const SUPPORTED_LANGUAGES = [ - { - locale: 'en', - name: 'English', - default: true, - }, -] - -export const SUPPORTED_LOCALES = SUPPORTED_LANGUAGES.map((l) => l.locale) - -export const DEFAULT_LANGUAGE = SUPPORTED_LANGUAGES.find((l) => l.default) - -export const DEFAULT_LOCALE = DEFAULT_LANGUAGE?.locale - -export function extractLocaleFromPath(path = '') { - const [_, maybeLocale] = path.split('/') - return SUPPORTED_LOCALES.includes(maybeLocale) ? maybeLocale : DEFAULT_LOCALE -} \ No newline at end of file diff --git a/packages/app/src/i18n/translations/en.json b/packages/app/src/i18n/translations/en.json deleted file mode 100644 index 882f0b28..00000000 --- a/packages/app/src/i18n/translations/en.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "main": { - "welcome": "Welcome back, {{username}}", - "assigned_for_you": "Assigned for you", - }, - "generics": { - "new": "New", - "close": "Close", - "done": "Done", - "edit": "Edit", - "select": "Select", - "save": "Save", - "cancel": "Cancel", - "delete": "Delete", - "import": "Import", - "export": "Export", - "refresh": "Refresh", - "reload": "Reload", - "search": "Search", - "status": "Status", - "type": "Type", - "about": "About", - "current": "Current", - "statistics": "Statistics", - "name": "Name", - "username": "Username", - "email": "Email", - "password": "Password", - "sessions": "Sessions", - "compact_view": "Compact view", - "about_us": "About us", - "sign_in": "Sign in", - "sign_out": "Sign out", - "sign_up": "Sign up", - "all_sessions": "All sessions", - "destroy_all_sessions": "Destroy all sessions", - "account_info": "Account information", - "password_confirmation": "Password confirmation", - }, - "fabric": { - "new_product": "New product", - "description": "Description", - "describe_something": "Describe something...", - "operations": "Operations", - "select_operation": "Select operation", - "select_operations": "Select operations...", - "tasks": "Tasks", - "select_task": "Select task", - "select_tasks": "Select tasks...", - "add_task": "Add task", - "add_tasks": "Add tasks", - "location": "Location", - "select_location": "Select location", - "select_locations": "Select locations...", - "add_location": "Add location", - "add_locations": "Add locations", - "variants": "Variants", - "select_variant": "Select variant", - "select_variants": "Select variants..." - }, - "settings": { - "general": { - "groupLabel": "General", - "language": "Language", - "persistentSession:": "Save session", - "persistentSession_description": "The application will save the session and no expire it." - }, - "sidebar": { - "groupLabel": "Sidebar", - "edit": "Edit sidebar", - "edit_description": "Edit the sidebar to add or remove items.", - "edit_button": "Edit", - "autoCollapse": "Auto collapse", - "autoCollapse_description": "Collapse the sidebar when loose focus" - }, - "aspect": { - "groupLabel": "Aspect", - "reduceAnimation": "Reduce animation", - "reduceAnimation_description": "Reduce the animation of the application.", - "darkMode": "Dark mode", - "darkMode_description": "Enable dark mode for the application.", - "primaryColor": "Primary color", - "primaryColor_description": "Change the primary color of the application.", - "resetTheme": "Reset theme", - "resetTheme_description": "Reset the theme to the default." - } - }, -} \ No newline at end of file diff --git a/packages/app/src/layout/bottombar/index.jsx b/packages/app/src/layout/bottombar/index.jsx index a1d947b9..c3cf963e 100644 --- a/packages/app/src/layout/bottombar/index.jsx +++ b/packages/app/src/layout/bottombar/index.jsx @@ -1,16 +1,45 @@ import React from "react" +import { Motion, spring } from "react-motion" +import { EviteComponent } from "evite" import * as antd from "antd" import { createIconRender } from "components/Icons" +import classnames from "classnames" import "./index.less" -export default class BottomBar extends React.Component { +export default class BottomBar extends EviteComponent { state = { - render: null + allowed: true, + show: false, + visible: false, + creatorActionsVisible: false, + render: null, + isManager: false, + } + + handleBusEvents = { + "render_initialization": () => { + this.toogle(false) + }, + "render_initialization_done": () => { + if (this.isAllowed()) { + this.toogle(true) + } + }, + "crash": () => { + this.toogle(false) + }, + "locationChange": () => { + this.toogle(this.isAllowed()) + } } componentDidMount = () => { + this._loadBusEvents() + window.app.BottomBarController = { + toogleVisible: this.toogle, + isVisible: () => this.state.visible, render: (fragment) => { this.setState({ render: fragment }) }, @@ -20,6 +49,35 @@ export default class BottomBar extends React.Component { } } + componentWillUnmount = () => { + this._unloadBusEvents() + delete window.app.BottomBarController + } + + isAllowed() { + return app.pageStatement?.bottomBarAllowed !== "undefined" && app.pageStatement?.bottomBarAllowed !== false + } + + toogle = (to) => { + if (!window.isMobile) { + to = false + } else { + to = to ?? !this.state.visible + } + + if (!to) { + this.setState({ show: to }, () => { + setTimeout(() => { + this.setState({ visible: to }) + }, 500) + }) + } else { + this.setState({ visible: to }, () => { + this.setState({ show: to }) + }) + } + } + onClickItemId = (id) => { window.app.setLocation(`/${id}`) } @@ -31,38 +89,81 @@ export default class BottomBar extends React.Component {
} - return
-
-
window.app.openFabric()} key="fabric" id="fabric" className="item"> -
- {createIconRender("PlusCircle")} + if (!this.state.visible) { + return null + } + + return + {({ y }) =>
+
+ +
window.app.goMain()} + > +
+ {createIconRender("Home")} +
+ +
window.app.openCreateNew()} + > +
+ {createIconRender("PlusCircle")} +
+
+
window.app.openSettings()} + > +
+ {createIconRender("Settings")} +
+
+ {this.props.user ?
window.app.goToAccount()} + > +
+ +
+
:
this.onClickItemId("login")} + className="item" + > +
+ {createIconRender("LogIn")} +
+
}
-
window.app.goMain()} key="main" id="main" className="item"> -
- {createIconRender("Home")} -
-
-
this.onClickItemId("nav")} key="nav" id="nav" className="item"> -
- {createIconRender("Navigation")} -
-
-
window.app.openSettings()} key="settings" id="settings" className="item"> -
- {createIconRender("Settings")} -
-
- {this.props.user ?
window.app.goToAccount()} key="account" id="account" className="item"> -
- -
-
:
this.onClickItemId("login")} className="item"> -
- {createIconRender("LogIn")} -
-
} -
-
+
} + } } \ No newline at end of file diff --git a/packages/app/src/layout/bottombar/index.less b/packages/app/src/layout/bottombar/index.less index 3663c033..c61b0ddc 100644 --- a/packages/app/src/layout/bottombar/index.less +++ b/packages/app/src/layout/bottombar/index.less @@ -1,4 +1,5 @@ @bottomBar_height: 80px; +@bottomBar_iconSize: 24px; .bottomBar { position: sticky; @@ -8,6 +9,10 @@ bottom: 0; width: 100vw; + min-width: 100vw; + max-width: 100vw; + + overflow: hidden; display: flex; flex-direction: row; @@ -23,22 +28,33 @@ padding: 10px; .items { - display: inline-block; - white-space: nowrap; - overflow-x: overlay; + display: inline-flex; + align-items: center; + justify-content: center; + //white-space: nowrap; + //overflow-x: overlay; height: 100%; - - > div { - display: inline-block; - - height: 100%; - margin: 0 17px; - } + width: 100vw; .item { + display: inline-flex; + + height: 100%; + align-items: center; + justify-content: center; + + transition: all 150ms ease-in; + + width: 20vw; + min-width: 20vw; + max-width: 20vw; + .icon { + border-radius: 360px; height: 100%; + width: fit-content; + display: flex; flex-direction: column; @@ -47,6 +63,28 @@ color: var(--background-color-contrast); font-size: 2rem; + + padding: 12px; + transition: all 70ms ease-in-out; + + svg{ + margin: 0!important; + } + } + + &.primary { + .icon { + color: var(--background-color-primary); + background-color: var(--primaryColor); + } + } + } + + .item:active { + .icon { + background-color: var(--background-color-primary); + color: var(--background-color-contrast); + transform: scale(0.9); } } } diff --git a/packages/app/src/layout/drawer/index.jsx b/packages/app/src/layout/drawer/index.jsx index 38030e67..1599fe4b 100644 --- a/packages/app/src/layout/drawer/index.jsx +++ b/packages/app/src/layout/drawer/index.jsx @@ -1,8 +1,6 @@ import React from "react" -import * as antd from "antd" -import classnames from "classnames" +import { DraggableDrawer } from "components" import EventEmitter from "@foxify/events" -import { Icons } from "components/Icons" import "./index.less" @@ -104,13 +102,9 @@ export class Drawer extends React.Component { options = this.props.options ?? {} events = new EventEmitter() state = { - locked: this.options.locked ?? false, - visible: false, + visible: true, } - unlock = () => this.setState({ locked: false }) - lock = () => this.setState({ locked: true }) - componentDidMount = async () => { if (typeof this.props.controller === "undefined") { throw new Error(`Cannot mount an drawer without an controller`) @@ -118,30 +112,23 @@ export class Drawer extends React.Component { if (typeof this.props.children === "undefined") { throw new Error(`Empty component`) } - - if (this.props.children) { - this.setState({ visible: true }) - } } - onClose = () => { - if (typeof this.options.props?.closable !== "undefined" && !this.options.props?.closable) { - return false - } - this.close() + toogleVisibility = (to) => { + this.setState({ visible: to ?? !this.state.visible }) } - close = (context) => { - if (typeof this.options.onClose === "function") { - this.options.onClose(...context) - } - - this.setState({ visible: false }) - this.unlock() + close = () => { + this.toogleVisibility(false) + this.events.emit("beforeClose") setTimeout(() => { + if (typeof this.options.onClose === "function") { + this.options.onClose() + } + this.props.controller.destroy(this.props.id) - }, 400) + }, 500) } sendEvent = (...context) => { @@ -162,14 +149,13 @@ export class Drawer extends React.Component { render() { const drawerProps = { - destroyOnClose: true, - bodyStyle: { padding: 0 }, ...this.options.props, ref: this.props.ref, - closable: false, key: this.props.id, - onClose: this.onClose, - visible: this.state.visible, + onRequestClose: this.close, + open: this.state.visible, + containerElementClass: "drawer", + modalElementClass: "body", } const componentProps = { ...this.options.componentProps, @@ -179,25 +165,8 @@ export class Drawer extends React.Component { handleFail: this.handleFail, } - if (window.isMobile) { - drawerProps.height = "100%" - drawerProps.placement = "bottom" - } - - return ( - - {!this.props.headerDisabled &&
- } - subTitle={this.props.subtitle} - /> -
} -
- {React.createElement(this.props.children, componentProps)} -
-
- ) + return + {React.createElement(this.props.children, componentProps)} + } } \ No newline at end of file diff --git a/packages/app/src/layout/drawer/index.less b/packages/app/src/layout/drawer/index.less index 0d2295fd..591016b7 100644 --- a/packages/app/src/layout/drawer/index.less +++ b/packages/app/src/layout/drawer/index.less @@ -1,52 +1,26 @@ -@import "theme/vars.less"; - .drawer { - height: 100vh; - max-height: 100vh; - - .pageTitle { - position: sticky; - background-color: var(--background-color-primary); - top: 0; - z-index: 100; - height: @app_header_height; - - padding: 10px 20px; - - .ant-page-header{ - height: 100%; - padding: 0; - } - .ant-page-header-heading{ - height: 100%; - margin: 0!important; - } - .ant-page-header-heading-left { - height: 100%; - margin: 0!important; - } - } - .body { - z-index: 99; + position: absolute; + top: 50px; - position: relative; - - padding: 20px 30px; - height: 100vh - @app_header_height; + padding: 30px 10px 10px 10px; + background-color: var(--background-color-primary); width: 100%; + max-width: 700px; + min-height: 100%; + border-top-left-radius: 8px; + border-top-right-radius: 8px; } - &.mobile { - .body { - padding: 20px 15px; - } + .body::before{ + content: ""; + background-color: var(--background-color-contrast); + width: 100px; + height: 8px; + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + border-radius: 8px; } -} - -.ant-drawer-content, -.ant-drawer-wrapper-body, -.ant-drawer-body { - height: 100vh; - max-height: 100vh; -} +} \ No newline at end of file diff --git a/packages/app/src/layout/header/index.jsx b/packages/app/src/layout/header/index.jsx index a30d5b12..760c4373 100644 --- a/packages/app/src/layout/header/index.jsx +++ b/packages/app/src/layout/header/index.jsx @@ -28,28 +28,12 @@ export default class Header extends React.Component { window.app["HeaderController"] = this.HeaderController } - onClickCreate = () => { - window.app.openFabric() - } - - onClickHome = () => { - window.app.goMain() - } - render() { return ( - {window.isMobile &&
- } - /> -
}
} diff --git a/packages/app/src/layout/header/index.less b/packages/app/src/layout/header/index.less index ab61f932..223632a0 100644 --- a/packages/app/src/layout/header/index.less +++ b/packages/app/src/layout/header/index.less @@ -2,7 +2,8 @@ .app_header { user-select: none; - + --webkit-user-select: none; + display: flex; flex-direction: row; align-items: center; diff --git a/packages/app/src/layout/index.jsx b/packages/app/src/layout/index.jsx index 68f4b614..d2d2ba90 100644 --- a/packages/app/src/layout/index.jsx +++ b/packages/app/src/layout/index.jsx @@ -1,13 +1,12 @@ import React from "react" import classnames from "classnames" import * as antd from 'antd' -import { enquireScreen, unenquireScreen } from 'enquire-js' import Sidebar from './sidebar' import Header from './header' import Drawer from './drawer' import Sidedrawer from './sidedrawer' -import BottomBar from "./bottombar" +import BottomBar from "./bottomBar" const LayoutRenders = { mobile: (props) => { @@ -43,7 +42,6 @@ const LayoutRenders = { export default class Layout extends React.Component { state = { layoutType: "default", - isMobile: false, isOnTransition: false, } @@ -58,35 +56,29 @@ export default class Layout extends React.Component { } componentDidMount() { - this.enquireHandler = enquireScreen(mobile => { - const { isMobile } = this.state - - if (isMobile !== mobile) { - window.isMobile = mobile - this.setState({ - isMobile: mobile, - }) - } - - if (mobile) { - window.app.eventBus.emit("mobile_mode") - this.setLayout("mobile") - } else { - window.app.eventBus.emit("desktop_mode") - this.setLayout("default") - } - }) - window.app.eventBus.on("transitionStart", () => { this.setState({ isOnTransition: true }) }) window.app.eventBus.on("transitionDone", () => { this.setState({ isOnTransition: false }) }) - } - componentWillUnmount() { - unenquireScreen(this.enquireHandler) + if (window.app.settings.get("forceMobileMode") || window.app.isAppCapacitor() || Math.min(window.screen.width, window.screen.height) < 768 || navigator.userAgent.indexOf("Mobi") > -1) { + window.isMobile = true + this.setLayout("mobile") + } else { + window.isMobile = false + } + + window.app.eventBus.on("forceMobileMode", (to) => { + if (to) { + window.isMobile = true + this.setLayout("mobile") + } else { + window.isMobile = false + this.setLayout("default") + } + }) } render() { diff --git a/packages/app/src/layout/sidebar/components/editor/index.jsx b/packages/app/src/layout/sidebar/components/editor/index.jsx index 96492fef..bb145ce6 100644 --- a/packages/app/src/layout/sidebar/components/editor/index.jsx +++ b/packages/app/src/layout/sidebar/components/editor/index.jsx @@ -1,14 +1,13 @@ import React from "react" import { Button } from "antd" -import { ActionsBar } from "components" -import { Icons, createIconRender } from "components/icons" import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd" -import Selector from "../selector" - +import { ActionsBar } from "components" +import { Icons, createIconRender } from "components/Icons" import sidebarItems from "schemas/routes.json" -import defaultSidebarKeys from "schemas/defaultSidebar.json" +import { sidebarKeys as defaultSidebarKeys } from "schemas/defaultSettings" +import Selector from "../selector" import "./index.less" const allItemsMap = [...sidebarItems].map((item, index) => { @@ -47,7 +46,7 @@ export default class SidebarEditor extends React.Component { } loadItems = () => { - const storagedKeys = window.app.configuration.sidebar.get() ?? defaultSidebarKeys + const storagedKeys = window.app.settings.get("sidebarKeys") ?? defaultSidebarKeys const active = [] const lockedIndex = [] @@ -65,7 +64,7 @@ export default class SidebarEditor extends React.Component { } onSave = () => { - window.app.configuration.sidebar._push(this.state.items) + window.app.settings.set("sidebarKeys", this.state.items) window.app.SidebarController.toogleEdit(false) } @@ -74,7 +73,7 @@ export default class SidebarEditor extends React.Component { } onSetDefaults = () => { - window.app.configuration.sidebar._push(defaultSidebarKeys) + window.app.settings.set("sidebarKeys", defaultSidebarKeys) this.loadItems() } @@ -136,7 +135,7 @@ export default class SidebarEditor extends React.Component { if (update.includes(key)) { return false } - + update.push(key) }) @@ -164,8 +163,8 @@ export default class SidebarEditor extends React.Component { background: component.locked ? "rgba(145, 145, 145, 0.2)" : isDragging - ? "rgba(145, 145, 145, 0.5)" - : "transparent", + ? "rgba(145, 145, 145, 0.5)" + : "transparent", ...draggableStyle, }) @@ -244,7 +243,7 @@ export default class SidebarEditor extends React.Component { Done
- +
diff --git a/packages/app/src/layout/sidebar/components/selector/index.jsx b/packages/app/src/layout/sidebar/components/selector/index.jsx index 8207c091..b778816f 100644 --- a/packages/app/src/layout/sidebar/components/selector/index.jsx +++ b/packages/app/src/layout/sidebar/components/selector/index.jsx @@ -8,7 +8,7 @@ import sidebarItems from "schemas/routes.json" import "./index.less" const getStoragedKeys = () => { - return window.app.configuration.sidebar.get() + return window.app.settings.get("sidebarKeys") ?? [] } const getAllItems = () => { diff --git a/packages/app/src/layout/sidebar/index.jsx b/packages/app/src/layout/sidebar/index.jsx index 6eee53c7..cdedd5d2 100644 --- a/packages/app/src/layout/sidebar/index.jsx +++ b/packages/app/src/layout/sidebar/index.jsx @@ -1,14 +1,14 @@ import React from "react" -import { Icons, createIconRender } from "components/Icons" import { Layout, Menu, Avatar } from "antd" - -import { SidebarEditor } from "./components" - -import config from "config" -import sidebarItems from "schemas/routes.json" -import defaultSidebarItems from "schemas/defaultSidebar.json" import classnames from "classnames" +import config from "config" +import { Icons, createIconRender } from "components/Icons" +import { sidebarKeys as defaultSidebarItems } from "schemas/defaultSettings" +import sidebarItems from "schemas/routes.json" +import { Translation } from "react-i18next" + +import { SidebarEditor } from "./components" import "./index.less" const { Sider } = Layout @@ -27,7 +27,7 @@ export default class Sidebar extends React.Component { editMode: false, visible: true, loading: true, - collapsed: window.app.configuration.settings.get("collapseOnLooseFocus") ?? false, + collapsed: window.app.settings.get("collapseOnLooseFocus") ?? false, pathResolve: {}, menus: {}, extraItems: { @@ -57,7 +57,7 @@ export default class Sidebar extends React.Component { } getStoragedKeys = () => { - return window.app.configuration?.sidebar.get() + return window.app.settings.get("sidebarKeys") } appendItem = (item = {}) => { @@ -108,7 +108,7 @@ export default class Sidebar extends React.Component { keys = keys.move(index, item.index) //update index - window.app.configuration.sidebar._push(keys) + window.app.settings.set("sidebarKeys", keys) } } }) @@ -165,7 +165,11 @@ export default class Sidebar extends React.Component { {item.title}} + title={ + + {t => t(item.title)} + + } {...item.props} > {this.renderMenuItems(item.children)} @@ -175,7 +179,9 @@ export default class Sidebar extends React.Component { return ( - {item.title ?? item.id} + + {t => t(item.title ?? item.id)} + ) }) @@ -203,7 +209,7 @@ export default class Sidebar extends React.Component { } if (to) { - window.app.eventBus.emit("cleanAll") + window.app.eventBus.emit("clearAllOverlays") } else { if (this.itemsMap !== this.getStoragedKeys()) { this.loadSidebarItems() @@ -214,7 +220,7 @@ export default class Sidebar extends React.Component { } toogleCollapse = (to) => { - if (window.app.configuration?.settings.is("collapseOnLooseFocus", true) && !this.state.editMode) { + if (window.app.settings.is("collapseOnLooseFocus", true) && !this.state.editMode) { this.setState({ collapsed: to ?? !this.state.collapsed }) } else { this.setState({ collapsed: false }) @@ -290,7 +296,9 @@ export default class Sidebar extends React.Component {
}> - Settings + + {t => t("Settings")} + diff --git a/packages/app/src/layout/sidebar/index.less b/packages/app/src/layout/sidebar/index.less index 9a829b27..e13c0e3d 100644 --- a/packages/app/src/layout/sidebar/index.less +++ b/packages/app/src/layout/sidebar/index.less @@ -34,6 +34,8 @@ background-color: transparent !important; user-select: none; + --webkit-user-select: none; + transition: all 150ms ease-in-out; height: 100%; display: flex; @@ -65,6 +67,7 @@ background-color: transparent !important; user-select: none; + --webkit-user-select: none; display: flex; flex-direction: column; @@ -75,6 +78,7 @@ .app_sidebar_header_logo { user-select: none; + --webkit-user-select: none; display: flex; align-items: center; @@ -82,6 +86,7 @@ img { user-select: none; + --webkit-user-select: none; width: 80%; max-height: 80px; @@ -107,6 +112,7 @@ background: transparent !important; background-color: transparent !important; backdrop-filter: blur(10px); + --webkit-backdrop-filter: blur(10px); width: 100%; height: fit-content; diff --git a/packages/app/src/models/index.js b/packages/app/src/models/index.js index ffa18c12..b70d767f 100644 --- a/packages/app/src/models/index.js +++ b/packages/app/src/models/index.js @@ -1,4 +1,2 @@ -export { default as Session } from './session' -export { default as User } from './user' -export { default as SidebarController } from './sidebar' -export { default as SettingsController } from './settings' \ No newline at end of file +export { default as Session } from "./session" +export { default as User } from "./user" \ No newline at end of file diff --git a/packages/app/src/models/session/index.js b/packages/app/src/models/session/index.js index ddbb74b4..677c0e0a 100644 --- a/packages/app/src/models/session/index.js +++ b/packages/app/src/models/session/index.js @@ -1,32 +1,44 @@ -import cookies from 'js-cookie' +import cookies from "js-cookie" import jwt_decode from "jwt-decode" -import config from 'config' +import config from "config" +import { Storage } from '@capacitor/storage' export default class Session { static get bridge() { return window.app?.request } + static capStorage = async (method, value) => { + const res = await Storage[method]({ key: this.tokenKey, value }) + return res.value + } + static tokenKey = config.app?.storage?.token ?? "token" static get token() { - if (navigator.userAgent === "capacitor") { - // FIXME: sorry about that - return sessionStorage.getItem(this.tokenKey) + if (window.app.isAppCapacitor()) { + return this.capStorage("get") } return cookies.get(this.tokenKey) } static set token(token) { - if (navigator.userAgent === "capacitor") { - // FIXME: sorry about that - return sessionStorage.setItem(this.tokenKey, token) + if (window.app.isAppCapacitor()) { + return this.capStorage("set", token) } return cookies.set(this.tokenKey, token) } - static get decodedToken() { - return this.token && jwt_decode(this.token) + static async delToken() { + if (window.app.isAppCapacitor()) { + return this.capStorage("remove") + } + return cookies.remove(Session.tokenKey) + } + + static async decodedToken() { + const token = await this.token + return token && jwt_decode(token) } //* BASIC HANDLERS @@ -34,18 +46,17 @@ export default class Session { const body = { username: window.btoa(payload.username), password: window.btoa(payload.password), - allowRegenerate: payload.allowRegenerate } return this.generateNewToken(body, (err, res) => { - if (typeof callback === 'function') { + if (typeof callback === "function") { callback(err, res) } if (!err || res.status === 200) { let token = res.data - if (typeof token === 'object') { + if (typeof token === "object") { token = token.token } @@ -66,28 +77,28 @@ export default class Session { parseData: false }) - if (typeof callback === 'function') { + if (typeof callback === "function") { callback(request.error, request.response) } return request } - regenerateToken = async () => { - return await Session.bridge.post.regenerate() - } - //* GETTERS getAllSessions = async () => { return await Session.bridge.get.sessions() } getTokenInfo = async () => { - const session = Session.token + const session = await Session.token return await Session.bridge.post.validateSession({ session }) } + getCurrentSession = async () => { + return await Session.bridge.get.currentSession() + } + isCurrentTokenValid = async () => { const health = await this.getTokenInfo() @@ -95,15 +106,11 @@ export default class Session { } forgetLocalSession = () => { - if (navigator.userAgent === "capacitor") { - // FIXME: sorry about that - return sessionStorage.removeItem(Session.tokenKey) - } - return cookies.remove(Session.tokenKey) + return Session.delToken() } destroyAllSessions = async () => { - const session = Session.decodedToken + const session = await Session.decodedToken() if (!session) { return false @@ -117,8 +124,8 @@ export default class Session { } destroyCurrentSession = async () => { - const token = Session.token - const session = Session.decodedToken + const token = await Session.token + const session = await Session.decodedToken() if (!session || !token) { return false diff --git a/packages/app/src/models/settings/index.js b/packages/app/src/models/settings/index.js deleted file mode 100644 index 63722949..00000000 --- a/packages/app/src/models/settings/index.js +++ /dev/null @@ -1,60 +0,0 @@ -import store from 'store' -import { objectToArrayMap } from '@corenode/utils' -import defaultKeys from "schemas/defaultSettings.json" - -class SettingsController { - constructor() { - this.storeKey = "app_settings" - this.defaultSettings = defaultKeys - - this.settings = store.get(this.storeKey) ?? {} - - objectToArrayMap(this.defaultSettings).forEach((entry) => { - if (typeof this.settings[entry.key] === "undefined") { - this.settings[entry.key] = entry.value - } - }) - - return this - } - - _pull() { - this.settings = { ...this.settings, ...store.get(this.storeKey) } - } - - _push(update) { - if (typeof update !== "undefined") { - this.settings = { ...this.settings, ...update } - } - store.set(this.storeKey, this.settings) - } - - is = (key, value) => { - return this.settings[key] === value ? true : false - } - - change = (key, to) => { - let value = to ?? !this.settings[key] ?? true - - this.set(key, value) - window.app.eventBus.emit("changeSetting", { key, value, to }) - - return this.settings - } - - set = (key, value) => { - this.settings[key] = value - store.set(this.storeKey, this.settings) - - return this.settings - } - - get = (key) => { - if (typeof key === "undefined") { - return this.settings - } - return this.settings[key] - } -} - -export default SettingsController \ No newline at end of file diff --git a/packages/app/src/models/sidebar/index.js b/packages/app/src/models/sidebar/index.js deleted file mode 100644 index af108fcc..00000000 --- a/packages/app/src/models/sidebar/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import store from 'store' -import defaultKeys from 'schemas/defaultSidebar.json' - -class SidebarController { - constructor() { - this.storeKey = "app_sidebar" - this.defaults = defaultKeys - - this.data = store.get(this.storeKey) ?? this.defaults - return this - } - - _pull = () => { - this.data = [...this.data, ...store.get(this.storeKey)] - } - - _push = (update) => { - if (typeof update !== "undefined") { - this.data = update - } - store.set(this.storeKey, this.data) - } - - set = (value) => { - this.data.push(value) - this._push() - - return this.data - } - - all = () => { - let objs = [] - - this.data.forEach((entry) => { - objs.push(entry) - }) - - return objs - } - - get = () => { - return this.data - } -} - -export default SidebarController \ No newline at end of file diff --git a/packages/app/src/models/user/index.js b/packages/app/src/models/user/index.js index d77cb319..dda14f57 100644 --- a/packages/app/src/models/user/index.js +++ b/packages/app/src/models/user/index.js @@ -1,12 +1,12 @@ -import Session from '../session' +import Session from "../session" export default class User { static get bridge() { return window.app?.request } - static get data() { - const token = Session.decodedToken + static async data() { + const token = await Session.decodedToken() if (!token || !User.bridge) { return false @@ -15,24 +15,44 @@ export default class User { return User.bridge.get.user(undefined, { username: token.username, _id: token.user_id }) } - static get roles() { - const token = Session.decodedToken + static async roles() { + const token = await Session.decodedToken() if (!token || !User.bridge) { return false } - return User.bridge.get.roles({ username: token.username }) + return User.bridge.get.userRoles(undefined, { username: token.username }) } - getAssignedWorkloads = async () => { - const token = Session.decodedToken + static async hasRole(role) { + const roles = await User.roles() + + if (!roles) { + return false + } + + return Array.isArray(roles) && roles.includes(role) + } + + static async selfUserId() { + const token = await Session.decodedToken() + + if (!token) { + return false + } + + return token.user_id + } + + getAssignedWorkorders = async () => { + const token = await Session.decodedToken() if (!token || !User.bridge) { return false } - return User.bridge.get.workloads({ username: token.username }) + return User.bridge.get.workorders({ username: token.username }) } getData = async (payload, callback) => { @@ -48,12 +68,12 @@ export default class User { } hasAdmin = async () => { - const roles = await User.roles + const roles = await User.roles() if (!roles) { return false } - + return Array.isArray(roles) && roles.includes("admin") } } \ No newline at end of file diff --git a/packages/app/src/pages/account/components/editor/index.jsx b/packages/app/src/pages/account/components/editor/index.jsx deleted file mode 100644 index dda0195d..00000000 --- a/packages/app/src/pages/account/components/editor/index.jsx +++ /dev/null @@ -1,181 +0,0 @@ -import React from "react" -import debounce from "lodash/debounce" -import { Icons } from "components/Icons" - -import classnames from "classnames" -import * as antd from "antd" - -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) - } - } - - window.app.eventBus.on("discardAllChanges", () => { - setCurrentValue(defaultValue) - }) - - const RenderComponent = component - return ( -
- {header ? header : null} - -
- ) -} - -export default class EditAccount extends React.Component { - state = { - values: this.props.user ?? {}, - changes: [], - loading: false, - } - - toogleLoading = (to) => { - this.setState({ loading: to ?? !this.state.loading }) - } - - onSaveDone = (error, data) => { - this.setState({ changes: [] }) - this.toogleLoading(false) - } - - onSave = () => { - this.props.onSave(this.state.changes, this.onSaveDone) - } - - discardAll = () => { - window.app.eventBus.emit("discardAllChanges") - this.setState({ changes: [] }) // clean changes after emit, cause controller wont handle changes - } - - handleChange = (event) => { - const { id, value } = event - let changes = [...this.state.changes] - - changes = changes.filter((change) => change.id !== id) - - if (this.state.values[id] !== value) { - // changes detected - changes.push({ id, value }) - } - - this.setState({ changes }) - } - - renderActions = () => { - return ( -
0 })}> -
- {this.state.loading && } - {this.state.changes.length} Changes -
-
- - Save - -
-
- - Discard all - -
-
- ) - } - - render() { - const { username, fullName, email } = this.state.values - - return ( -
- {this.renderActions()} -
-
-

- Account information -

- - Username -
- } - component={antd.Input} - props={{ placeholder: "Username", disabled: true }} - handleChange={this.handleChange} - /> - - Name -
- } - component={antd.Input} - props={{ placeholder: "Your full name" }} - handleChange={this.handleChange} - /> - - Email -
- } - component={antd.Input} - props={{ placeholder: "Your email address", type: "email" }} - handleChange={this.handleChange} - /> -
-
-
- ) - } -} diff --git a/packages/app/src/pages/account/components/index.js b/packages/app/src/pages/account/components/index.js index e6d93aef..79765944 100644 --- a/packages/app/src/pages/account/components/index.js +++ b/packages/app/src/pages/account/components/index.js @@ -1,3 +1,2 @@ -export { default as AccountEditor } from './editor' export { default as SessionsView } from './sessionsView' export { default as StatisticsView } from './statisticsView' \ No newline at end of file diff --git a/packages/app/src/pages/account/components/sessionsView/index.jsx b/packages/app/src/pages/account/components/sessionsView/index.jsx index 841120ec..e6c33bcb 100644 --- a/packages/app/src/pages/account/components/sessionsView/index.jsx +++ b/packages/app/src/pages/account/components/sessionsView/index.jsx @@ -1,8 +1,73 @@ import React from "react" import * as antd from "antd" -import { Sessions } from "components" +import { Skeleton } from "components" +import { Icons } from "components/Icons" +import { Session } from "models" + +import "./index.less" + +const SessionsList = (props) => { + const sessions = props.sessions.map((session) => { + const header = ( +
+
+ +
+
{session.session_uuid}
+
{props.current === session.session_uuid ? Current : ""}
+
+ ) + + const renderDate = () => { + const dateNumber = Number(session.date) + + if (dateNumber) { + return new Date(dateNumber).toString() + } + return session.date + } + + return ( + +
+
+ + {renderDate()} +
+
+ + {session.location} +
+
+
+ ) + }) + + if (!props.sessions || !Array.isArray(props.sessions)) { + return
+ + Cannot find any valid sessions + +
+ } + + return
+ + {sessions} + +
+} export default class SessionsView extends React.Component { + state = { + currentSessionUUID: null, + } + + componentDidMount = async () => { + const currentSession = await Session.decodedToken() + this.setState({ currentSessionUUID: currentSession?.session_uuid }) + } + signOutAll = () => { antd.Modal.warning({ title: "Caution", @@ -20,20 +85,29 @@ export default class SessionsView extends React.Component { } render() { - const { sessions, decodedToken } = this.props + const { sessions } = this.props if (!sessions) { - return + return } return ( -
- - {sessions && ( - - Destroy all sessions - - )} +
+
+
+

All Sessions

+
+
+ {sessions && ( + + Destroy all sessions + + )} +
+
+
+ +
) } diff --git a/packages/app/src/pages/account/components/sessionsView/index.less b/packages/app/src/pages/account/components/sessionsView/index.less new file mode 100644 index 00000000..99378dd8 --- /dev/null +++ b/packages/app/src/pages/account/components/sessionsView/index.less @@ -0,0 +1,52 @@ +.sessions_wrapper { + .ant-collapse-borderless { + background-color: transparent !important; + } + + .header { + h1, + h2, + h3 { + margin: 0; + } + + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + + > div { + margin-bottom: 10px; + } +} + +.session_entry { + display: flex; + flex-direction: column; + + background: transparent; + margin-bottom: 10px; + + border: 1px solid #ccc !important; + border-radius: 12px !important; + + .session_entry_info { + > div { + padding: 4px 40px; + } + } + .ant-collapse-header { + display: flex; + align-items: center; + } +} + +.session_header { + display: flex; + flex-direction: row; + + > div { + padding: 0 10px; + } +} diff --git a/packages/app/src/pages/account/index.jsx b/packages/app/src/pages/account/index.jsx index 71776ba6..23a4ee59 100644 --- a/packages/app/src/pages/account/index.jsx +++ b/packages/app/src/pages/account/index.jsx @@ -1,14 +1,15 @@ import React from "react" import * as antd from "antd" +import { Translation } from "react-i18next" + import { Icons } from "components/Icons" +import { Skeleton, ActionsBar, AdminTools } from "components" +import { Session, User } from "models" -import { AccountEditor, SessionsView, StatisticsView } from "./components" - -import { Session } from "models" +import { SessionsView, StatisticsView } from "./components" import "./index.less" - const SelfViewComponents = { sessionsView: SessionsView, statisticsView: StatisticsView, @@ -62,6 +63,7 @@ export default class Account extends React.Component { static bindApp = ["userController", "sessionController"] state = { + hasManager: false, isSelf: false, user: null, sessions: null @@ -70,7 +72,7 @@ export default class Account extends React.Component { api = window.app.request componentDidMount = async () => { - const token = Session.decodedToken + const token = await Session.decodedToken() const location = window.app.history.location const query = new URLSearchParams(location.search) @@ -86,88 +88,84 @@ export default class Account extends React.Component { state.user = await this.props.contexts.app.userController.getData({ username: requestedUser }) } + state.hasManager = await User.hasRole("manager") + state.hasAdmin = await User.hasRole("admin") + this.setState(state) } - handleUpdateUserData = async (changes, callback) => { - const update = {} - - if (Array.isArray(changes)) { - changes.forEach((change) => { - update[change.id] = change.value - }) - } - - await this.api.put - .selfUser(update) - .then((data) => { - callback(false, data) - }) - .catch((err) => { - callback(true, err) - }) - - window.app.eventBus.emit("reinitializeUser") - } - handleSignOutAll = () => { return this.props.contexts.app.sessionController.destroyAllSessions() } - openUserEdit = () => { - window.app.DrawerController.open("editAccount", AccountEditor, { - props: { - keyboard: false, - width: "45%", - bodyStyle: { - overflow: "hidden", - }, - }, - componentProps: { - onSave: this.handleUpdateUserData, - user: this.state.user, - }, - }) + openUserEdit = async () => { + const result = await AdminTools.open.dataManager(this.state.user) + + if (result) { + this.setState({ user: result }) + } } - renderSelfActions = () => { - if (this.state.isSelf) { - return ( -
- Edit -
- ) - } - - return null + openRolesManager = async () => { + await AdminTools.open.rolesManager(this.state.user._id) } render() { const user = this.state.user if (!user) { - return + return } return (
-
- -
- {Boolean(user.fullName) ? - <> -

{user.fullName}

- @{user.username}#{user._id} - : - <> -

@{user.username}

- #{user._id} - - } +
+
+ +
+ {Boolean(user.fullName) ? + <> +

{user.fullName}

+ @{user.username}#{user._id} + : + <> +

@{user.username}

+ #{user._id} + + } +
+
+
+
+ {user.roles.map((role, index) => { + return {role} + })} +
- {this.state.isSelf && this.renderSelfActions()}
+ + {(this.state.isSelf || this.state.hasManager) && + } + shape="round" + onClick={this.openUserEdit} + > + + {(t) => <>{t("Edit")}} + + + {this.state.hasAdmin && } + shape="round" + onClick={this.openRolesManager} + > + + {(t) => <>{t("Manage roles")}} + + } + } + {this.state.isSelf && ( diff --git a/packages/app/src/pages/account/index.less b/packages/app/src/pages/account/index.less index a35a0e88..9becffef 100644 --- a/packages/app/src/pages/account/index.less +++ b/packages/app/src/pages/account/index.less @@ -1,30 +1,61 @@ -.account_wrapper{ - > div { - margin-bottom: 10px; - } -} +@borderColor: #33333396; +@borderRadius: 12px; -.account_card{ - display: flex; - font-family: 'Roboto Mono', monospace; +.account_wrapper { + .card { + display: flex; + flex-direction: column; - align-items: center; + .header { + position: relative; + display: inline-flex; + align-items: center; - padding: 12px; - color: #333; - border: 1px solid #33333396; - border-radius: 12px; + z-index: 15; + width: 100%; + padding: 12px; - img { - width: 70px; - height: 70px; - } + font-family: "Roboto Mono", monospace; + color: #333; + + border: 1px solid @borderColor; + border-radius: @borderRadius; + + word-break: break-all; + + img { + width: 70px; + height: 70px; + } + + h1 { + margin: 0; + font-size: 35px; + span { + font-size: 12px; + } + } + } + + .extension { + position: relative; + display: inline-flex; + + top: -10px; + width: 100%; + padding: 19px 10px 10px 10px; + + border-top: 0; + border-right: 0; + + border-style: solid; + border-width: 1px; + border-color: transparent @borderColor @borderColor @borderColor; + border-radius: 0 0 @borderRadius @borderRadius; + } + } - h1 { - margin: 0; - font-size: 35px; - span { - font-size: 12px; - } - } + > div { + margin-bottom: 10px; + } } \ No newline at end of file diff --git a/packages/app/src/pages/changelogs/index.jsx b/packages/app/src/pages/changelogs/index.jsx new file mode 100644 index 00000000..fc62d919 --- /dev/null +++ b/packages/app/src/pages/changelogs/index.jsx @@ -0,0 +1,9 @@ +import React from "react" + +export default () => { + // TODO: Fetch API Release notes + + return
+

Changelog

+
+} \ No newline at end of file diff --git a/packages/app/src/pages/explore/index.jsx b/packages/app/src/pages/explore/index.jsx new file mode 100644 index 00000000..f316774e --- /dev/null +++ b/packages/app/src/pages/explore/index.jsx @@ -0,0 +1,68 @@ +import React from "react" +import * as antd from "antd" +import { PostCard } from "components" + +import "./index.less" + +export default class PostsExplorer extends React.Component { + state = { + loading: true, + posts: [], + } + + api = window.app.request + + componentDidMount = async () => { + this.toogleLoading(true) + + await this.fetchPosts() + + window.app.ws.listen(`new.post`, (data) => { + this.addPost(data) + }) + + this.toogleLoading(false) + } + + toogleLoading = (to) => { + this.setState({ loading: to ?? !this.state.loading }) + } + + addPost = (post) => { + this.setState({ + posts: [post, ...this.state.posts], + }) + } + + fetchPosts = async () => { + const posts = await this.api.get.feed().catch(error => { + console.error(error) + antd.message.error(error) + + return false + }) + + if (posts) { + console.log(posts) + this.setState({ posts }) + } + + } + + renderPosts = (posts) => { + if (!Array.isArray(posts)) { + antd.message.error("Failed to render posts") + return null + } + + return posts.map((post) => { + return + }) + } + + render() { + return
+ {this.state.loading ? : this.renderPosts(this.state.posts)} +
+ } +} \ No newline at end of file diff --git a/packages/app/src/pages/explore/index.less b/packages/app/src/pages/explore/index.less new file mode 100644 index 00000000..7f08ff6d --- /dev/null +++ b/packages/app/src/pages/explore/index.less @@ -0,0 +1,11 @@ +.explore { + display: flex; + flex-direction: column; + align-items: center; + + width: 100%; + + > div { + margin-bottom: 15px; + } +} \ No newline at end of file diff --git a/packages/app/src/pages/licenses/index.jsx b/packages/app/src/pages/licenses/index.jsx new file mode 100644 index 00000000..7f223228 --- /dev/null +++ b/packages/app/src/pages/licenses/index.jsx @@ -0,0 +1,34 @@ +import React from "react" +import config from "config" + +import "./index.less" + +export default () => { + const [licenses, setLicenses] = React.useState([]) + + const loadLicenses = async () => { + const deps = Object.entries(config.package.dependencies).reduce((acc, [name, version]) => { + acc.push({ + name, + version, + }) + + return acc + }, []) + + setLicenses(deps) + } + + React.useEffect(() => { + loadLicenses() + }, []) + + return
+ {licenses.map((license) => { + return
+

{license.name}

+

{license.version}

+
+ })} +
+} diff --git a/packages/app/src/pages/licenses/index.less b/packages/app/src/pages/licenses/index.less new file mode 100644 index 00000000..0f86c2dc --- /dev/null +++ b/packages/app/src/pages/licenses/index.less @@ -0,0 +1,9 @@ +.tpd_list { + display: inline-flex; + flex-direction: column; + overflow-y: scroll!important; + + .item { + + } +} \ No newline at end of file diff --git a/packages/app/src/pages/login/index.jsx b/packages/app/src/pages/login/index.jsx index 7a0f3587..05313e36 100644 --- a/packages/app/src/pages/login/index.jsx +++ b/packages/app/src/pages/login/index.jsx @@ -1,8 +1,9 @@ -import React from 'react' -import config from "config" +import React from "react" import * as antd from "antd" -import { FormGenerator } from 'components' -import { Icons } from 'components/Icons' +import { FormGenerator } from "components" +import { Icons } from "components/Icons" + +import config from "config" import "./index.less" @@ -13,7 +14,11 @@ const formInstance = [ component: "Input", icon: "User", placeholder: "Username", - props: null + props: { + autocorrect: "off", + autocapitalize: "none", + className: "login-form-username", + }, }, item: { hasFeedback: true, @@ -23,7 +28,6 @@ const formInstance = [ message: 'Please input your Username!', }, ], - props: null } }, { @@ -59,22 +63,15 @@ const formInstance = [ } } }, - { - id: "allowRegenerate", - withValidation: false, - element: { - component: "Checkbox", - props: { - children: "Not expire", - defaultChecked: false, - } - } - } ] export default class Login extends React.Component { static bindApp = ["sessionController"] + static pageStatement = { + bottomBarAllowed: false + } + handleFinish = async (values, ctx) => { ctx.toogleValidation(true) @@ -111,11 +108,14 @@ export default class Login extends React.Component { } componentDidMount() { - if (window.app.SidebarController.isVisible()) { + const sidebarVisible = window.app.SidebarController.isVisible() + const headerVisible = window.app.HeaderController.isVisible() + + if (sidebarVisible) { window.app.SidebarController.toogleVisible(false) } - if (window.app.HeaderController.isVisible()) { + if (headerVisible) { window.app.HeaderController.toogleVisible(false) } } @@ -123,7 +123,7 @@ export default class Login extends React.Component { render() { return (
- {this.props.session?.valid &&
+ {this.props.session &&

You already have a valid session.

@{this.props.session.username} diff --git a/packages/app/src/pages/login/index.less b/packages/app/src/pages/login/index.less index 4ae1a815..30f1dad9 100644 --- a/packages/app/src/pages/login/index.less +++ b/packages/app/src/pages/login/index.less @@ -19,6 +19,15 @@ justify-content: center; } +.login-form-username { + font-size: 20px!important; + + input { + padding: 20px!important; + font-size: 20px!important; + } +} + .session_available { width: fit-content; height: fit-content; diff --git a/packages/app/src/pages/main/index.jsx b/packages/app/src/pages/main/index.jsx index d3cc293b..62e4a297 100644 --- a/packages/app/src/pages/main/index.jsx +++ b/packages/app/src/pages/main/index.jsx @@ -1,14 +1,15 @@ import React from "react" import * as antd from "antd" -import { AppSearcher, ServerStatus, Clock } from "components" +import { Icons } from "components/Icons" + +import { AppSearcher, ServerStatus, Clock, } from "components" +import { Translation } from "react-i18next" import "./index.less" // TODO: Customizable main menu export default class Main extends React.Component { - api = window.app.request - - componentDidMount() { + componentDidMount = async () => { if (!window.isMobile && window.app?.HeaderController?.isVisible()) { window.app.HeaderController.toogleVisible(false) } @@ -25,27 +26,32 @@ export default class Main extends React.Component { return (
-
-
-
- -
-
-
- -
-
-

Welcome back, {user.fullName ?? user.username ?? "Guest"}

-
- {!window.isMobile &&
- -
} -
+
+
+ +
+
+
+ +
+
+ { + (t) =>

{t("main_welcome")} {user.fullName ?? user.username ?? "Guest"}

+ }
+
+ {!window.isMobile &&
+ +
}
- {!window.isMobile &&
- -
}
+ + {!window.isMobile &&
+ +
}
) } diff --git a/packages/app/src/pages/main/index.less b/packages/app/src/pages/main/index.less index 05b377a7..3e23b7de 100644 --- a/packages/app/src/pages/main/index.less +++ b/packages/app/src/pages/main/index.less @@ -1,52 +1,51 @@ .dashboard { padding: 20px; width: 100%; + height: 100%; h1 { font-size: 28px; margin: 0; } - .top { - width: 100%; + > div { + margin-bottom: 20px; + } + + .header { display: flex; - align-items: center; - justify-content: space-between; > div { margin-right: 20px; } } - > div { - margin-bottom: 20px; - } - .content { > div { margin-left: 20px; } } - .assigned { + .quick_actions { + display: flex; + flex-wrap: wrap; + > div { - margin-left: 20px; + margin: 6px 10px; + } + } + + .widgets { + display: flex; + flex-direction: column; + + .widget { + //background-color: var(--background-color-accent); + //padding: 10px; + } + + > div { + margin-bottom: 20px; } } } - -.header_title { - display: flex; - > div { - margin-right: 20px; - } -} - -.quick_actions { - display: flex; - flex-wrap: wrap; - - > div { - margin: 6px 10px; - } -} diff --git a/packages/app/src/pages/streams/index.jsx b/packages/app/src/pages/streams/index.jsx index 37ec4f10..59110796 100644 --- a/packages/app/src/pages/streams/index.jsx +++ b/packages/app/src/pages/streams/index.jsx @@ -1,52 +1,55 @@ import React from 'react' import axios from "axios" import * as antd from "antd" -import { SelectableList } from "components" - -// http://192.168.1.144:8080/live/srgooglo_ff.m3u8 -const streamsApi = "http://media.ragestudio.net/api" -const bridge = axios.create({ - baseURL: streamsApi, - auth: { - username: "admin", - password: "sharedpass1414" - } -}) +import { SelectableList, ActionsBar } from "components" export default class Streams extends React.Component { state = { - list: {}, + list: [], + } + + api = window.app.request + + componentDidMount = async () => { + await this.updateStreamsList() } updateStreamsList = async () => { - const streams = ((await bridge.get("/streams")).data).live + const streams = await this.api.get.streams().catch(error => { + console.error(error) + antd.message.error(error) + + return false + }) + this.setState({ list: streams }) } - componentDidMount = async () => { - this.updateStreamsList() - } onClickItem = (item) => { window.app.setLocation(`/streams/viewer?key=${item}`) } - renderListItem = (key) => { - const streaming = this.state.list[key] - console.log(streaming) - return
this.onClickItem(key)}> -

@{streaming.publisher.stream} #{streaming.publisher.clientId}

+ renderListItem = (stream) => { + stream.StreamPath = stream.StreamPath.replace(/^\/live\//, "") + + return
this.onClickItem(stream.StreamPath)}> +

@{stream.StreamPath} #{stream.id}

} render() { return
-

Streams

+ +
+ Refresh +
+
diff --git a/packages/app/src/theme/fonts.css b/packages/app/src/theme/fonts.css index 65b92981..048bbf84 100644 --- a/packages/app/src/theme/fonts.css +++ b/packages/app/src/theme/fonts.css @@ -7,4 +7,5 @@ @import url("https://fonts.googleapis.com/css?family=Manrope:300,400,500,600,700&display=swap&subset=latin-ext"); @import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Space+Mono&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;1,300;1,400&display=swap'); \ No newline at end of file +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,300;0,400;1,300;1,400&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap'); \ No newline at end of file diff --git a/packages/app/src/theme/index.less b/packages/app/src/theme/index.less index 2b4b6aee..f65c3f3c 100644 --- a/packages/app/src/theme/index.less +++ b/packages/app/src/theme/index.less @@ -36,19 +36,12 @@ } html { - position: fixed; overflow: hidden; + height: 100%; -webkit-overflow-scrolling: touch; + background-color: var(--background-color-primary) !important; - height: 100vh; - max-height: 100vh; - min-height: 100vh; - - &.theme-dark { - @import "./variations/dark.less"; - } - svg { margin-right: 10px; vertical-align: -0.125em; @@ -56,31 +49,31 @@ html { } body { - text-rendering: optimizeLegibility !important; + overflow: hidden; + -webkit-overflow-scrolling: touch; -webkit-app-region: no-drag; - user-select: none; - background-color: var(--background-color-primary) !important; - font-family: "Varela Round", sans-serif; - - scroll-behavior: smooth; height: 100%; - overflow: hidden; + user-select: none; + --webkit-user-select: none; + + scroll-behavior: smooth; + text-rendering: optimizeLegibility !important; + + background-color: var(--background-color-primary) !important; + font-family: "Varela Round", sans-serif; } #root { - position: fixed; -webkit-overflow-scrolling: touch; + + position: fixed; overflow: hidden; - //position: absolute; + width: 100%; height: 100%; - height: 100vh; - - overflow-x: hidden; - overflow-y: hidden; background-color: var(--background-color-primary) !important; } @@ -106,7 +99,7 @@ body { width: 100%; height: 100%; - max-height: 100%; + max-height: 100vh; overflow: hidden; transition: all 150ms ease-in-out; @@ -122,7 +115,7 @@ body { } &.mobile { - padding-top: 20px; + //padding-top: 20px; ::-webkit-scrollbar { display: none !important; @@ -135,9 +128,9 @@ body { .layout_page { position: relative; + -webkit-overflow-scrolling: touch; height: 100%; - max-height: 100%; margin: 10px 10px 30px 16px; @@ -146,8 +139,21 @@ body { } @media (max-width: 768px) { - .layout_page  { - margin: 10px; + .layout_page { + padding: 10px; + margin: 0; + } + + h1, + h2, + h3, + h4, + h5, + h6, + span, + p { + user-select: none; + -webkit-user-select: none; } } @@ -179,39 +185,46 @@ body { transform: scale(0.8); } -.app_crash { - position: absolute; - z-index: 9999; - - top: 0; - right: 0; - +.app_initialization { width: 100vw; height: 100vh; + padding: 50px; display: flex; flex-direction: column; - justify-content: center; align-items: center; - .header { + > div { + width: 100%; + height: fit-content; + display: flex; - text-align: center; flex-direction: column; justify-content: center; - svg { - width: 100px; - height: 100px; - } + align-items: center; + + margin-bottom: 50px; } } +.app_crash_wrapper { + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + // Fixments .ant-btn { display: flex; align-items: center; justify-content: center; + + user-select: none; + --webkit-user-select: none; } .ant-result-extra { @@ -224,15 +237,13 @@ body { display: flex; } -.__render_box_test { - position: absolute; - z-index: 9999; +.ant-message { + svg { + margin: 0; + } +} - width: 40vw; - height: 40vw; - - background: red; - transition: all 50ms ease-out; - - filter: drop-shadow(20px 20px 3px rgba(0, 0, 0, 1)); +*:not(input):not(textarea) { + -webkit-user-select: none; /* disable selection/Copy of UIWebView */ + -webkit-touch-callout: none; /* disable the IOS popup when long-press on a link */ } \ No newline at end of file diff --git a/packages/app/src/utils/cursorPosition/index.js b/packages/app/src/utils/cursorPosition/index.js new file mode 100644 index 00000000..f56808a8 --- /dev/null +++ b/packages/app/src/utils/cursorPosition/index.js @@ -0,0 +1 @@ +export default (event) => event.touches ? event.touches[0].clientX : event.clientX \ No newline at end of file diff --git a/packages/app/src/utils/findChildById/index.js b/packages/app/src/utils/findChildById/index.js new file mode 100644 index 00000000..3fc98611 --- /dev/null +++ b/packages/app/src/utils/findChildById/index.js @@ -0,0 +1,24 @@ +const findChildById = (element, id,) => { + let returnElement = null + let lastChildren = element.childNodes + + for (let index = 0; index < lastChildren.length; index++) { + const child = lastChildren[index] + + if (child.id === id) { + returnElement = child + break + } + + if (child.childNodes.length > 0) { + returnElement = findChildById(child, id) + if (returnElement) { + break + } + } + } + + return returnElement +} + +export default findChildById \ No newline at end of file diff --git a/packages/app/src/utils/getBase64/index.js b/packages/app/src/utils/getBase64/index.js new file mode 100644 index 00000000..a8991081 --- /dev/null +++ b/packages/app/src/utils/getBase64/index.js @@ -0,0 +1,8 @@ +export default (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result) + reader.onerror = error => reject(error) + }) +} \ No newline at end of file diff --git a/packages/app/src/utils/haptics/index.js b/packages/app/src/utils/haptics/index.js new file mode 100644 index 00000000..7a70992c --- /dev/null +++ b/packages/app/src/utils/haptics/index.js @@ -0,0 +1,34 @@ +import { Haptics, ImpactStyle } from "@capacitor/haptics" + +export default { + selectionStart: async () => { + const enabled = window.app.settings.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionStart() + } + }, + selectionChanged: async () => { + const enabled = window.app.settings.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionChanged() + } + }, + selectionEnd: async () => { + const enabled = window.app.settings.get("haptic_feedback") + + if (enabled) { + await Haptics.selectionEnd() + } + }, + impact: async (style = "Medium") => { + const enabled = window.app.settings.get("haptic_feedback") + + if (enabled) { + style = String(style).toTitleCase() + + await Haptics.impact({ style: ImpactStyle[style] }) + } + } +} \ No newline at end of file diff --git a/packages/app/src/utils/index.js b/packages/app/src/utils/index.js new file mode 100644 index 00000000..84ddf0ab --- /dev/null +++ b/packages/app/src/utils/index.js @@ -0,0 +1,5 @@ +export { default as useLongPress } from "./useLongPress" +export { default as findChildById } from "./findChildById" +export { default as cursorPosition } from "./cursorPosition" +export { default as getBase64 } from "./getBase64" +export { default as Haptics } from "./haptics" \ No newline at end of file diff --git a/packages/app/src/utils/useLongPress/index.jsx b/packages/app/src/utils/useLongPress/index.jsx new file mode 100644 index 00000000..258719c7 --- /dev/null +++ b/packages/app/src/utils/useLongPress/index.jsx @@ -0,0 +1,74 @@ +import { useCallback, useRef, useState } from "react" + +export default ( + onLongPress, + onClick, + { + shouldPreventDefault = true, + delay = 300, + onTouchStart, + onTouchEnd, + } = {} +) => { + const [longPressTriggered, setLongPressTriggered] = useState(false) + const timeout = useRef() + const target = useRef() + + const start = useCallback( + event => { + if (shouldPreventDefault && event.target) { + event.target.addEventListener("touchend", preventDefault, { + passive: false + }) + target.current = event.target + } + + if (typeof onTouchStart === "function") { + onTouchStart() + } + + timeout.current = setTimeout(() => { + onLongPress(event) + setLongPressTriggered(true) + }, delay) + }, + [onLongPress, delay, shouldPreventDefault] + ) + + const clear = useCallback( + (event, shouldTriggerClick = true) => { + timeout.current && clearTimeout(timeout.current) + shouldTriggerClick && !longPressTriggered && onClick() + setLongPressTriggered(false) + + if (typeof onTouchEnd === "function") { + onTouchEnd() + } + + if (shouldPreventDefault && target.current) { + target.current.removeEventListener("touchend", preventDefault) + } + }, + [shouldPreventDefault, onClick, longPressTriggered] + ) + + return { + onMouseDown: e => start(e), + onTouchStart: e => start(e), + onMouseUp: e => clear(e), + onMouseLeave: e => clear(e, false), + onTouchEnd: e => clear(e) + } +} + +const isTouchEvent = event => { + return "touches" in event +} + +const preventDefault = event => { + if (!isTouchEvent(event)) return + + if (event.touches.length < 2 && event.preventDefault) { + event.preventDefault() + } +} \ No newline at end of file diff --git a/packages/app/vite.config.js b/packages/app/vite.config.js index 843b693e..d9116954 100644 --- a/packages/app/vite.config.js +++ b/packages/app/vite.config.js @@ -1,14 +1,15 @@ -import { defineConfig } from 'vite' import getConfig from "./.config.js" -import Pages from 'vite-plugin-pages' -import reactRefresh from '@vitejs/plugin-react-refresh' + +import { defineConfig } from "vite" +import Pages from "vite-plugin-pages" +import reactRefresh from "@vitejs/plugin-react-refresh" export default defineConfig({ plugins: [ reactRefresh(), Pages({ react: true, - extensions: ['jsx', 'tsx'], + extensions: ["jsx", "tsx"], }), ], ...getConfig(),