├── .gitignore ├── .idea ├── .gitignore ├── watcherTasks.xml ├── codeStyles │ └── codeStyleConfig.xml ├── misc.xml ├── vcs.xml ├── dictionaries │ └── Aktyn.xml ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml └── fivem-launcher.iml ├── icon.png ├── preview.png ├── src ├── config.json ├── styles │ ├── parameters.scss │ ├── Roboto-Regular.ttf │ ├── common.scss │ ├── animations.scss │ ├── settings.scss │ ├── server_item.scss │ ├── main.scss │ └── main_view.scss ├── index.html ├── index.tsx ├── servers.ts ├── common.ts └── components │ ├── server_item.tsx │ ├── main_view.tsx │ └── settings.tsx ├── README.md ├── LICENSE ├── main.js ├── package.json ├── webpack.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | electron_dist/ -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Default ignored files 3 | /workspace.xml -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aktyn/fivem-launcher/HEAD/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aktyn/fivem-launcher/HEAD/preview.png -------------------------------------------------------------------------------- /src/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "ip": "5.135.14.88", 4 | "port": 30134 5 | } 6 | } -------------------------------------------------------------------------------- /src/styles/parameters.scss: -------------------------------------------------------------------------------- 1 | $circleBtnSize: 50px; 2 | $circleBtnMargin: 5px; 3 | $fadingSpeed: 0.5s; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

FiveM launcher with editable servers list

2 | 3 | preview 4 | -------------------------------------------------------------------------------- /src/styles/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aktyn/fivem-launcher/HEAD/src/styles/Roboto-Regular.ttf -------------------------------------------------------------------------------- /.idea/watcherTasks.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/dictionaries/Aktyn.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | aktyn 5 | autoprefixer 6 | fivem 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /src/styles/common.scss: -------------------------------------------------------------------------------- 1 | @import "animations"; 2 | @import "parameters"; 3 | 4 | .error { 5 | color: #ef9a9a; 6 | font-weight: bold; 7 | } 8 | 9 | .circle-btn { 10 | width: $circleBtnSize; 11 | height: $circleBtnSize; 12 | border-radius: $circleBtnSize; 13 | margin: $circleBtnMargin; 14 | background-color: #fff; 15 | font-size: $circleBtnSize/2; 16 | font-weight: normal; 17 | 18 | opacity: 0; 19 | animation: pop-in 0.5s cubic-bezier(.87, -.41, .19, 1.44) forwards; 20 | } -------------------------------------------------------------------------------- /.idea/fivem-launcher.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom'; 4 | 5 | import './styles/main.scss'; 6 | import './styles/common.scss'; 7 | 8 | import MainView from "./components/main_view"; 9 | 10 | render( 11 | 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('page'), 18 | ); -------------------------------------------------------------------------------- /src/styles/animations.scss: -------------------------------------------------------------------------------- 1 | @keyframes fade-in { 2 | 0% { 3 | opacity: 0 4 | } 5 | 100% { 6 | opacity: 1 7 | } 8 | } 9 | 10 | @keyframes fade-out { 11 | 0% { 12 | opacity: 1 13 | } 14 | 100% { 15 | opacity: 0 16 | } 17 | } 18 | 19 | @keyframes fade-zoom-out { 20 | 0% { 21 | transform: scale(1); 22 | opacity: 1; 23 | } 24 | 100% { 25 | transform: scale(1.5); 26 | opacity: 0; 27 | } 28 | } 29 | 30 | @keyframes pop-in { 31 | 0% { 32 | opacity: 0; 33 | transform: scale(0); 34 | } 35 | 100% { 36 | opacity: 1; 37 | transform: scale(1); 38 | } 39 | } 40 | 41 | @keyframes slide-from-top { 42 | 0% { 43 | transform: translateY(-100%); 44 | } 45 | 100% { 46 | transform: translateY(0%); 47 | } 48 | } -------------------------------------------------------------------------------- /src/servers.ts: -------------------------------------------------------------------------------- 1 | import {ServerConfig} from "./common"; 2 | import Config from './config.json'; 3 | 4 | let _servers: ServerConfig[] = Config.server ? 5 | [Config.server] : JSON.parse( localStorage.getItem('servers') || '[]' ); 6 | console.log(_servers); 7 | 8 | export default { 9 | allowConfigure() { 10 | return !Config.server; 11 | }, 12 | 13 | save(servers: ServerConfig[]) { 14 | _servers = servers; 15 | localStorage.setItem('servers', JSON.stringify(servers)); 16 | }, 17 | 18 | getList() { 19 | return _servers; 20 | }, 21 | 22 | setCurrent(server: ServerConfig) { 23 | localStorage.setItem('current_server', JSON.stringify(server)); 24 | //this.setState({current_server: data}); 25 | }, 26 | 27 | getCurrent() { 28 | if(Config.server) 29 | return Config.server as unknown as ServerConfig; 30 | 31 | let current = JSON.parse( localStorage.getItem('current_server') || '{}' ); 32 | if(!current) 33 | return null; 34 | return _servers.find(s => { 35 | return current && s.ip === current.ip && s.port === current.port; 36 | }) || null; 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Radosław Krajewski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | if(process.env.NODE_ENV === 'production') { 2 | //@ts-ignore 3 | var {ipcRenderer} = require('electron'); 4 | } 5 | 6 | export interface ServerConfig { 7 | ip: string; 8 | port: number; 9 | } 10 | 11 | export function getJSON(to: string): Promise { 12 | if(process.env.NODE_ENV === 'production') { 13 | return new Promise((resolve, reject) => { 14 | let res = ipcRenderer.sendSync('get-request', to); 15 | resolve(res); 16 | }); 17 | } 18 | 19 | return fetch('https://cors-anywhere.herokuapp.com/' + to, { 20 | method: "GET", 21 | mode: 'cors',//'cors' : 'same-origin', 22 | headers: { 23 | "Content-Type": "application/json; charset=utf-8", 24 | "Accept-Encoding": "gzip, deflate", 25 | //"Origin": "http://localhost:3000", 26 | //"x-requested-with": "http://localhost:3000", 27 | //"range": 'bytes=0-1000000', 28 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3', 29 | 'Accept-Language': 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7', 30 | 'Cache-Control': 'no-cache', 31 | 'Connection': 'keep-alive', 32 | 'Host': '51.38.140.161:30110', 33 | 'Pragma': 'no-cache', 34 | 'Upgrade-Insecure-Requests': '1', 35 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36' 36 | }, 37 | }).then(res => res.json()); 38 | } 39 | 40 | export function trimString(str: string, max_len: number, suffix = '...') { 41 | if (str.length > max_len) 42 | return str.substr(0, max_len - suffix.length) + suffix; 43 | return str; 44 | } -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const {app, BrowserWindow, ipcMain} = require('electron'); 2 | const fetch = require('node-fetch'); 3 | 4 | console.log( process.env.NODE_ENV ); 5 | let is_dev = (process.env.NODE_ENV || '').trim() === 'dev'; 6 | 7 | app.on('ready', async () => { 8 | const window = new BrowserWindow({ 9 | icon: './icon.png', 10 | width: 600, 11 | height: 500, 12 | // useContentSize: true, 13 | title: 'FiveM Launcher', 14 | center: true, 15 | autoHideMenuBar: true, 16 | kiosk: false, 17 | webPreferences: { 18 | nodeIntegration: true, 19 | // contextIsolation: true, 20 | // sandbox: true, 21 | webSecurity: true//true 22 | }, 23 | }); 24 | 25 | window.webContents.on('crashed', () => { 26 | app.relaunch(); 27 | app.quit(); 28 | }); 29 | 30 | ipcMain.on('close-app', () => { 31 | console.log('Closing'); 32 | app.quit(); 33 | }); 34 | 35 | ipcMain.on('get-request', async (event, arg) => { 36 | try { 37 | event.returnValue = await fetch(arg, { 38 | method: "GET", 39 | mode: 'cors',//'cors' : 'same-origin', 40 | headers: { 41 | "Content-Type": "application/json; charset=utf-8", 42 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3', 43 | 'Accept-Encoding': ' gzip, deflate', 44 | 'Accept-Language': 'pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7', 45 | 'Cache-Control': 'no-cache', 46 | 'Connection': 'keep-alive', 47 | 'Host': '51.38.140.161:30110', 48 | 'Pragma': 'no-cache', 49 | 'Upgrade-Insecure-Requests': '1', 50 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36' 51 | }, 52 | }).then(res => res.json()); 53 | } 54 | catch (e) { 55 | console.error(e); 56 | event.returnValue = {}; 57 | } 58 | }); 59 | 60 | try { 61 | window.loadFile(`${__dirname}/dist/index.html`).catch(console.error); 62 | } 63 | catch(e) { 64 | console.log('Template loading error:', e); 65 | } 66 | window.setMenu(null); 67 | if(is_dev) 68 | window.webContents.openDevTools(); 69 | 70 | window.webContents.once('dom-ready', () => { 71 | //renderer is loaded 72 | }); 73 | }); 74 | 75 | app.on('window-all-closed', function() {//way to close electron on IOS 76 | if(process.platform !== 'darwin') 77 | app.quit() 78 | }); -------------------------------------------------------------------------------- /src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | @import "animations"; 2 | @import "parameters"; 3 | 4 | .settings-container { 5 | display: block; 6 | width: 100vw; 7 | height: 100vh; 8 | overflow: hidden; 9 | 10 | position: absolute; 11 | left: 0px; 12 | top: 0px; 13 | 14 | color: #fff; 15 | 16 | $background: #26A69A; 17 | $rippleAnimDuration: 0.8s; 18 | 19 | &.fading { 20 | animation: fade-zoom-out $fadingSpeed cubic-bezier(.36, .07, .19, .97) forwards; 21 | } 22 | 23 | & > .settings { 24 | display: block; 25 | 26 | z-index: 2; 27 | position: absolute; 28 | left: 0px; 29 | top: 0px; 30 | 31 | width: 100%; 32 | height: 100%; 33 | background-color: $background; 34 | 35 | opacity: 0; 36 | 37 | animation: fade-in $fadingSpeed ($rippleAnimDuration - $fadingSpeed) ease-in-out forwards; 38 | 39 | & > .closer { 40 | position: absolute; 41 | right: 0px; 42 | top: 0px; 43 | animation-delay: ($rippleAnimDuration - $fadingSpeed); 44 | } 45 | 46 | .servers-options { 47 | display: grid; 48 | max-height: 100vh; 49 | width: 100%; 50 | grid-template-columns: auto; 51 | grid-template-rows: 30px auto 100px; 52 | align-items: center; 53 | justify-content: center; 54 | 55 | & > .scrollable-list { 56 | overflow-x: hidden; 57 | overflow-y: auto; 58 | max-height: calc(100vh - 30px - 100px); 59 | width: 100%; 60 | } 61 | 62 | .adder { 63 | margin: $circleBtnMargin auto; 64 | animation-delay: ($rippleAnimDuration - $fadingSpeed + 0.1s); 65 | } 66 | } 67 | } 68 | 69 | & > .ripple { 70 | z-index: 1; 71 | 72 | $size: calc(100vh + 100vw); 73 | 74 | content: ""; 75 | display: block; 76 | position: absolute; 77 | 78 | width: $size; 79 | height: $size; 80 | border-radius: $size; 81 | 82 | background-color: $background; 83 | 84 | transform: translate(-50%, -50%) scale(1); 85 | 86 | animation: ripple $rippleAnimDuration cubic-bezier(.36, .07, .19, .97) forwards, 87 | fade-out $fadingSpeed $rippleAnimDuration ease-in-out forwards; 88 | 89 | @keyframes ripple { 90 | 0% { 91 | transform: translate(-50%, -50%) scale(0); 92 | opacity: 0.5; 93 | } 94 | 50% { 95 | opacity: 1; 96 | } 97 | 100% { 98 | transform: translate(-50%, -50%) scale(1.5); 99 | opacity: 1; 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /src/styles/server_item.scss: -------------------------------------------------------------------------------- 1 | .server-item { 2 | $size: 28px; 3 | $indicatorSize: 20px; 4 | 5 | background-color: #e57373; 6 | border-bottom: 3px solid #0003; 7 | box-sizing: border-box; 8 | 9 | height: $size; 10 | line-height: $size - 3px; 11 | min-width: 242px; 12 | display: grid; 13 | grid-template-columns: fit-content(100%) auto fit-content(100%) fit-content(100%) fit-content(100%); 14 | align-items: center; 15 | grid-column-gap: 5px; 16 | 17 | padding: 0px 5px 0px 10px; 18 | margin: 6px $indicatorSize 3px; 19 | border-radius: $size; 20 | 21 | transition: background-color 0.3s ease-in-out; 22 | 23 | &:not(.prompt) { 24 | cursor: pointer; 25 | &:hover { 26 | background-color: #ef9a9a; 27 | } 28 | 29 | &.online { 30 | background-color: #8BC34A; 31 | 32 | &:hover { 33 | background-color: #9CCC65; 34 | } 35 | } 36 | } 37 | 38 | &.prompt { 39 | padding: 0px 0px; 40 | } 41 | 42 | position: relative; 43 | &::before { 44 | content: "⮞"; 45 | display: inline-block; 46 | position: absolute; 47 | font-size: 20px; 48 | color: #80CBC4; 49 | left: 0px; 50 | opacity: 0; 51 | transition: opacity 0.4s ease-in-out, transform 0.4s cubic-bezier(.87, -.41, .19, 1.44); 52 | } 53 | 54 | &.current::before { 55 | transform: translateX(-$indicatorSize); 56 | opacity: 1; 57 | } 58 | 59 | & > * { 60 | margin: 0px; 61 | padding: 0px; 62 | overflow: hidden; 63 | white-space: nowrap; 64 | } 65 | 66 | img, .icon-span { 67 | max-height: $size - 3px; 68 | } 69 | 70 | button { 71 | background-color: transparent; 72 | padding: 0px; 73 | box-shadow: none !important; 74 | color: #fff; 75 | 76 | &:hover { 77 | color: #fff; 78 | } 79 | } 80 | 81 | .remove-btn { 82 | width: 20px; 83 | height: 20px; 84 | font-weight: normal; 85 | font-size: 14px; 86 | 87 | opacity: 0.61; 88 | 89 | &:hover { 90 | color: #fff; 91 | opacity: 1; 92 | transform: scale(1.2); 93 | } 94 | } 95 | 96 | .remove-prompt { 97 | grid-column: 1 / span 5; 98 | 99 | display: grid; 100 | grid-template-columns: 1fr 1fr; 101 | align-items: stretch; 102 | justify-content: stretch; 103 | 104 | & > * {//buttons 105 | background-color: transparent; 106 | height: $size; 107 | line-height: $size; 108 | border-radius: $size; 109 | padding: 0px 15px; 110 | 111 | &:hover { 112 | background-color: #fff2; 113 | } 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fivem-launcher", 3 | "version": "1.0.0", 4 | "description": "electron application", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "webpack-dev-server --config ./webpack.config.js", 8 | "publish": "set NODE_ENV=production&& webpack --config ./webpack.config.js -p", 9 | "l:publish": "NODE_ENV=production webpack --config ./webpack.config.js -p", 10 | "run_electron": "set NODE_ENV=dev && electron ./main", 11 | "l:run_electron": "NODE_ENV=dev electron ./main", 12 | "preview": "npm run publish && set NODE_ENV=dev && electron ./main", 13 | "electron:rebuild": "npm rebuild --runtime=electron --target=3.0.7 --disturl=https://atom.io/download/atom-shell --abi=64", 14 | "pack": "npm run publish && set NODE_ENV=production&& electron-builder --win --x64 portable", 15 | "linux:pack": "npm run l:publish && NODE_ENV=production electron-builder --linux --x64 portable" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/Aktyn/fivem-launcher.git" 20 | }, 21 | "keywords": [ 22 | "fivem", 23 | "launcher", 24 | "electron", 25 | "webpack" 26 | ], 27 | "author": "Aktyn", 28 | "license": "UNLICENSED", 29 | "bugs": { 30 | "url": "https://github.com/Aktyn/fivem-launcher/issues" 31 | }, 32 | "homepage": "https://github.com/Aktyn/fivem-launcher#readme", 33 | "dependencies": { 34 | "@types/electron-json-storage": "^4.0.0", 35 | "@types/node": "^10.12.11", 36 | "@types/node-fetch": "^2.3.7", 37 | "@types/react": "^16.7.11", 38 | "@types/react-dom": "^16.0.11", 39 | "@types/react-router": "^4.4.1", 40 | "@types/react-router-dom": "^4.3.1", 41 | "node-fetch": "^2.6.0", 42 | "react": "^16.6.3", 43 | "react-dom": "^16.6.3", 44 | "react-router": "^4.3.1", 45 | "react-router-dom": "^4.3.1" 46 | }, 47 | "devDependencies": { 48 | "autoprefixer": "^9.3.1", 49 | "awesome-typescript-loader": "^5.2.1", 50 | "css-loader": "^1.0.1", 51 | "electron": "^5.0.6", 52 | "electron-builder": "^21.0.15", 53 | "electron-rebuild": "^1.8.5", 54 | "file-loader": "^2.0.0", 55 | "html-loader": "^0.5.5", 56 | "html-webpack-plugin": "^3.2.0", 57 | "image-webpack-loader": "^4.6.0", 58 | "mini-css-extract-plugin": "^0.4.5", 59 | "node-sass": "^4.10.0", 60 | "postcss-loader": "^3.0.0", 61 | "sass-loader": "^7.1.0", 62 | "style-loader": "^0.23.1", 63 | "typescript": "^3.2.1", 64 | "uglifyjs-webpack-plugin": "^2.0.1", 65 | "webpack": "^4.26.1", 66 | "webpack-cli": "^3.1.2", 67 | "webpack-dev-server": "^3.1.14" 68 | }, 69 | "build": { 70 | "appId": "fivem.launcher", 71 | "copyright": "Copyright © 2019 Aktyn", 72 | "win": { 73 | "target": "portable", 74 | "icon": "icon.png" 75 | }, 76 | "directories": { 77 | "output": "electron_dist" 78 | } 79 | }, 80 | "browser": { 81 | "fs": false 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/styles/main.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: RobotoRegular; 3 | font-display: auto; 4 | src: url('./Roboto-Regular.ttf'); 5 | } 6 | 7 | body, html, textarea, input, button, div { 8 | padding: 0px; 9 | margin: 0px; 10 | font-size: 12px; 11 | 12 | font-family: RobotoRegular, Arial, serif; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | 16 | box-sizing: border-box; 17 | vertical-align: middle; 18 | 19 | text-shadow: 0px 1px 1px #0003; 20 | } 21 | 22 | body, html { 23 | background-color: #455A64; 24 | color: #fff; 25 | text-align: center; 26 | } 27 | 28 | textarea, input { 29 | border: 1px solid rgb(169, 169, 169); 30 | background: #fff; 31 | outline: none; 32 | padding: 2px 5px; 33 | color: #455a64; 34 | font-size: 13px; 35 | 36 | &:focus { 37 | border-color: #e57373; 38 | } 39 | } 40 | 41 | hr { 42 | border: none; 43 | height: 1px; 44 | background: #90A4AE; 45 | margin: 15px 0px; 46 | } 47 | 48 | a { 49 | text-decoration: none; 50 | } 51 | 52 | button { 53 | outline: none; 54 | 55 | border: none; 56 | line-height: 100%; 57 | font-weight: bold; 58 | background: #fff; 59 | border-radius: 4px; 60 | padding: 5px 10px; 61 | color: #455a64; 62 | box-shadow: 0px 1px 3px #0008; 63 | 64 | &:not(:disabled) { 65 | cursor: pointer; 66 | 67 | &:hover { 68 | color: #607D8B; 69 | box-shadow: 0px 2px 3px #0008; 70 | } 71 | 72 | &:active { 73 | color: #f44336; 74 | box-shadow: 0px 0px 3px #0008; 75 | } 76 | } 77 | 78 | &:disabled { 79 | background-color: #B0BEC5; 80 | box-shadow: none; 81 | } 82 | } 83 | 84 | .close-btn, .menu-btn { 85 | background-size: 60%; 86 | background-color: #607D8B; 87 | background-position: center; 88 | background-repeat: no-repeat; 89 | 90 | height: 25px; 91 | width: 25px; 92 | border-radius: 25px; 93 | padding: 0px; 94 | 95 | &:hover { 96 | background-color: #78909C; 97 | } 98 | 99 | &:active { 100 | background-color: #e57373; 101 | } 102 | } 103 | 104 | /*main div*/ 105 | #main { 106 | display: block; 107 | position: relative; 108 | max-height: 100vh; 109 | 110 | & > .menu-container, & > .game-container { 111 | box-sizing: border-box; 112 | position: absolute; 113 | left: 0px; 114 | right: 0px; 115 | top: 0px; 116 | } 117 | } 118 | 119 | /* SCROLLBARS */ 120 | /* width */ 121 | ::-webkit-scrollbar { 122 | width: 8px; 123 | height: 6px; 124 | } 125 | 126 | /* Track */ 127 | ::-webkit-scrollbar-track { 128 | background: #0001; 129 | border-radius: 8px; 130 | } 131 | 132 | /* Handle */ 133 | ::-webkit-scrollbar-thumb { 134 | background: #fff2; 135 | border-radius: 8px; 136 | } 137 | 138 | /* Handle on hover */ 139 | ::-webkit-scrollbar-thumb:hover { 140 | background: #fff4; 141 | } -------------------------------------------------------------------------------- /src/components/server_item.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {getJSON, ServerConfig, trimString} from "../common"; 3 | 4 | import '../styles/server_item.scss'; 5 | 6 | let info_cache = new Map(); 10 | 11 | interface ServerInfo { 12 | maxPlayers: number; 13 | icon?: string; 14 | } 15 | 16 | interface ServerItemProps { 17 | data: ServerConfig; 18 | current: boolean; 19 | onSelected: () => void; 20 | onRemove: (online: boolean) => void; 21 | onServerOnline: () => void; 22 | } 23 | 24 | interface ServerItemState { 25 | info: ServerInfo | null; 26 | remove_prompt: boolean; 27 | } 28 | 29 | export default class ServerItem extends React.Component { 30 | private mounted = false; 31 | 32 | state: ServerItemState = { 33 | info: null, 34 | remove_prompt: false 35 | }; 36 | 37 | constructor(props: ServerItemProps) { 38 | super(props); 39 | } 40 | 41 | private get api_url() { 42 | return `http://${this.props.data.ip}:${this.props.data.port}`; 43 | } 44 | 45 | componentDidMount() { 46 | this.mounted = true; 47 | 48 | try { 49 | const url = `${this.api_url}/info.json`; 50 | let cache = info_cache.get(url); 51 | 52 | if(cache) { 53 | this.setState({ 54 | info: cache.info 55 | }); 56 | if(cache.info) 57 | this.props.onServerOnline(); 58 | 59 | if( Date.now() - cache.update_timestamp < 1000*30 ) 60 | return; 61 | } 62 | else 63 | info_cache.set(url, {info: null, update_timestamp: Date.now()}); 64 | 65 | getJSON(url).then(data => { 66 | if( !this.mounted ) 67 | return; 68 | const info: ServerInfo = { 69 | maxPlayers: parseInt(data['vars']['sv_maxClients']), 70 | icon: data['icon'], 71 | }; 72 | info_cache.set(url, {info, update_timestamp: Date.now()}); 73 | this.setState({info}); 74 | if(!cache) 75 | this.props.onServerOnline(); 76 | }).catch(void 0); 77 | } 78 | catch(e) {} 79 | } 80 | 81 | componentWillUnmount() { 82 | this.mounted = false; 83 | } 84 | 85 | render() { 86 | if(this.state.remove_prompt) { 87 | return
88 |
89 | 90 | 91 |
92 |
; 93 | } 94 | return
98 |
99 | {this.state.info && this.state.info.icon && 100 | {'server-icon'} 101 | } 102 |
103 |
{trimString(this.state.info ? '' : 'offline', 25)}
106 |
{this.state.info && (this.state.info.maxPlayers + ' slots')}
107 |
{this.props.data.ip}:{this.props.data.port}
108 | 112 |
; 113 | } 114 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //const webpack = require('webpack'); 2 | const path = require('path'); 3 | const autoprefixer = require('autoprefixer'); 4 | 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 7 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 8 | 9 | const isDevelopment = process.env.NODE_ENV !== 'production'; 10 | 11 | module.exports = { 12 | entry: { 13 | main: './src/index.tsx' 14 | }, 15 | output: { 16 | filename: '[name].js', 17 | chunkFilename: '[name].js', 18 | path: path.resolve(__dirname, 'dist'), 19 | }, 20 | mode: isDevelopment ? 'development' : 'production', 21 | target: isDevelopment ? 'web' : 'electron-renderer', 22 | node: { 23 | fs: "empty" 24 | }, 25 | devtool: isDevelopment && "source-map", 26 | devServer: { 27 | historyApiFallback: true, 28 | port: 3000, 29 | open: true 30 | }, 31 | resolve: { 32 | extensions: ['.js', '.json', '.ts', '.tsx'], 33 | }, 34 | 35 | optimization: isDevelopment ? undefined : { 36 | minimize: true, 37 | minimizer: [ 38 | new UglifyJsPlugin({ 39 | exclude: 'sw.js', 40 | uglifyOptions: { 41 | output: { 42 | comments: false 43 | }, 44 | ie8: false, 45 | toplevel: true 46 | } 47 | }) 48 | ], 49 | /*splitChunks: { 50 | //chunks: 'all', 51 | automaticNameDelimiter: '-', 52 | 53 | cacheGroups: { 54 | styles: { 55 | name: 'styles', 56 | test: /\.s?css$/, 57 | chunks: 'all', 58 | // minChunks: 1, 59 | priority: -1, 60 | reuseExistingChunk: true, 61 | enforce: true, 62 | } 63 | } 64 | }*/ 65 | }, 66 | 67 | module: { 68 | rules: [ 69 | { 70 | test: /\.(ts|tsx)$/, 71 | loader: 'awesome-typescript-loader', 72 | }, 73 | { 74 | test: /\.handlebars$/, 75 | loader: "handlebars-loader" 76 | }, 77 | { 78 | test: /\.(scss|css)$/, 79 | use: [ 80 | MiniCssExtractPlugin.loader, 81 | { 82 | loader: "css-loader", 83 | options: { 84 | sourceMap: isDevelopment, 85 | minimize: !isDevelopment 86 | } 87 | }, 88 | { 89 | loader: "postcss-loader", 90 | options: { 91 | autoprefixer: { 92 | browsers: 'last 2 versions, > 1%' 93 | }, 94 | sourceMap: isDevelopment, 95 | plugins: () => [ 96 | autoprefixer 97 | ] 98 | }, 99 | }, 100 | { 101 | loader: "sass-loader", 102 | options: { 103 | sourceMap: isDevelopment 104 | } 105 | } 106 | ] 107 | }, 108 | { 109 | test: /\.(jpe?g|png|gif|svg|ttf)$/, 110 | use: [ 111 | { 112 | loader: "file-loader", 113 | options: { 114 | attrs: ['img:src','link:href','image:xlink:href'], 115 | name: '[name].[ext]', 116 | outputPath: 'static/', 117 | useRelativePath: true, 118 | } 119 | }, 120 | { 121 | loader: 'image-webpack-loader', 122 | options: { 123 | mozjpeg: { 124 | progressive: true, 125 | quality: 80 126 | }, 127 | optipng: { 128 | enabled: true, 129 | }, 130 | pngquant: { 131 | quality: '80-90', 132 | speed: 4 133 | }, 134 | gifsicle: { 135 | interlaced: false, 136 | }, 137 | /*webp: { 138 | quality: 75 139 | }*/ 140 | } 141 | } 142 | ] 143 | }, 144 | ], 145 | }, 146 | 147 | plugins: [ 148 | /*new webpack.DefinePlugin({ 149 | _GLOBALS_: JSON.stringify({ 150 | update_time: Date.now() 151 | }) 152 | }),*/ 153 | new MiniCssExtractPlugin({ 154 | filename: "[name]-styles.css", 155 | chunkFilename: "[id].css" 156 | }), 157 | new HtmlWebpackPlugin({ 158 | hash: isDevelopment, 159 | favicon: './icon.png', 160 | title: 'FiveM Launcher', 161 | minify: !isDevelopment, 162 | template: './src/index.html', 163 | filename: './index.html', 164 | 165 | //inject: 'head', 166 | }) 167 | ] 168 | }; -------------------------------------------------------------------------------- /src/styles/main_view.scss: -------------------------------------------------------------------------------- 1 | @import "animations"; 2 | @import "parameters"; 3 | 4 | main { 5 | display: grid; 6 | grid-template-columns: 1fr fit-content(100%) 1fr; 7 | justify-content: stretch; 8 | 9 | max-height: 100vh; 10 | max-width: 100vw; 11 | overflow: hidden; 12 | 13 | position: relative; 14 | background: linear-gradient(45deg, #37474F 0%, #263738 100%); 15 | color: #CFD8DC; 16 | 17 | .columns-separator { 18 | width: 1px; 19 | background-color: #37474F; 20 | margin: 25px 5px; 21 | } 22 | 23 | .left-column, .right-column { 24 | height: 100vh; 25 | width: 100%; 26 | } 27 | 28 | .left-column { 29 | display: grid; 30 | grid-template-columns: auto; 31 | grid-template-rows: fit-content(100%) auto auto fit-content(100%); 32 | align-items: stretch; 33 | 34 | .servers-btn { 35 | width: 100%; 36 | padding: 10px; 37 | border-radius: 0px 0px 80px 80px; 38 | background-color: #26A69A; 39 | color: #fff; 40 | 41 | transition: background-color 0.3s ease-in-out, box-shadow 0.3s ease-in-out; 42 | transform: translateY(-150%); 43 | animation: slide-from-top 0.6s 1s cubic-bezier(.36,.07,.19,.97) forwards; 44 | 45 | &:not(.disabled):hover { 46 | color: #fff; 47 | background-color: #4DB6AC; 48 | } 49 | &.disabled { 50 | cursor: auto; 51 | } 52 | 53 | &.offline { 54 | background-color: #e57373; 55 | &::after { 56 | content: "OFFLINE"; 57 | } 58 | } 59 | &.online { 60 | &::after { 61 | content: "ONLINE"; 62 | } 63 | } 64 | } 65 | 66 | .server-info { 67 | padding-top: 20px; 68 | font-size: 14px; 69 | 70 | & > * { 71 | font-size: inherit; 72 | display: block; 73 | } 74 | 75 | .offline { 76 | color: #e57373; 77 | font-weight: bold; 78 | font-size: 17px; 79 | margin: 10px 0px; 80 | 81 | opacity: 0; 82 | animation: fade-in 0.6s 2s ease-in-out forwards; 83 | } 84 | 85 | .address { 86 | font-weight: bold; 87 | font-size: 16px; 88 | margin: 15px 0px; 89 | text-shadow: none; 90 | } 91 | 92 | img { 93 | max-height: 128px; 94 | } 95 | } 96 | 97 | .connect-btn { 98 | text-decoration: none; 99 | color: #fff; 100 | font-weight: bold; 101 | font-size: 16px; 102 | border: 1px solid #fff; 103 | padding: 10px 15px 10px 20px; 104 | border-radius: 40px; 105 | background-color: #fff0; 106 | box-shadow: 0px 1px 4px #0008; 107 | transition: background-color 0.4s cubic-bezier(.36, .07, .19, .97), 108 | color 0.4s cubic-bezier(.36, .07, .19, .97), border-color 0.4s cubic-bezier(.36, .07, .19, .97); 109 | 110 | animation: pop-in 0.5s cubic-bezier(.87, -.41, .19, 1.44) forwards; 111 | 112 | &:hover { 113 | background-color: #fff; 114 | color: #e57373; 115 | border-color: #e57373; 116 | } 117 | } 118 | } 119 | 120 | .right-column { 121 | max-height: 100%; 122 | overflow-y: auto; 123 | 124 | .players-list {//table 125 | border-collapse: collapse; 126 | width: 100%; 127 | 128 | thead { 129 | height: 30px; 130 | font-weight: bold; 131 | } 132 | 133 | tbody { 134 | text-align: left; 135 | 136 | tr > td:not(:nth-child(2)) { 137 | color: #B0BEC5; 138 | } 139 | } 140 | 141 | tr > td, tr > th { 142 | padding: 5px 5px; 143 | font-size: 13px; 144 | } 145 | 146 | tr:not(:last-child) { 147 | border-bottom: 1px solid #37474F; 148 | } 149 | } 150 | } 151 | 152 | .author { 153 | text-align: left; 154 | font-size: 10px; 155 | color: #78909C; 156 | padding: 3px; 157 | } 158 | 159 | .add-server-form { 160 | margin: $circleBtnMargin auto; 161 | 162 | & > * { 163 | margin: 5px auto; 164 | min-width: 100px; 165 | } 166 | 167 | span { 168 | font-weight: bold; 169 | margin: 0px 3px; 170 | } 171 | 172 | input { 173 | background-color: #B2DFDB40; 174 | padding: 4px; 175 | border-radius: 0px; 176 | color: #fff; 177 | font-weight: normal; 178 | text-align: center; 179 | border: none; 180 | border-bottom: 2px solid #80CBC4; 181 | overflow: visible; 182 | 183 | transition: border-color 0.4s ease-in-out, background-color 0.4s ease-in-out; 184 | 185 | &:focus { 186 | border-color: #80CBC4; 187 | background-color: #B2DFDB00; 188 | 189 | &::placeholder { 190 | color: #80CBC4; 191 | } 192 | } 193 | 194 | &.incorrect { 195 | border-color: #e57373; 196 | } 197 | 198 | &::placeholder { 199 | color: #00897B; 200 | text-shadow: none; 201 | transition: color 0.4s ease-in-out; 202 | } 203 | } 204 | } 205 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "./src/**/*" 4 | ], 5 | "exclude": [ 6 | "node_modules" 7 | ], 8 | "compilerOptions": { 9 | /* Basic Options */ 10 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 11 | "module": "ESNext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 12 | "lib": ["es6", "dom"], /* Specify library files to be included in the compilation. */ 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | "sourceMap": false, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", 20 | "skipLibCheck": true, /* Concatenate and emit output to single file. */ 21 | "outDir": "./dist/", /* Redirect output structure to the directory. */ 22 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 23 | // "composite": true, /* Enable project compilation */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true, /* Enable all strict type-checking options. */ 32 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | "strictNullChecks": true, /* Enable strict null checks. */ 34 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 36 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 37 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 38 | 39 | /* Additional Checks */ 40 | "noUnusedLocals": true, /* Report errors on unused locals. */ 41 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 42 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 43 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 44 | 45 | /* Module Resolution Options */ 46 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 49 | // "rootDirs": ["./server", "./include"], /* List of root folders whose combined content represents the structure of the project at runtime. */ 50 | // "typeRoots": [], /* List of folders to include type definitions from. */ 51 | // "types": [], /* Type declaration files to be included in compilation. */ 52 | "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 53 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 55 | 56 | /* Source Map Options */ 57 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 60 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 61 | 62 | /* Experimental Options */ 63 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 64 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 65 | 66 | /* Advanced Options */ 67 | "resolveJsonModule": true /* Include modules imported with '.json' extension */ 68 | } 69 | } -------------------------------------------------------------------------------- /src/components/main_view.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {getJSON, ServerConfig, trimString} from "../common"; 3 | import Servers from '../servers'; 4 | import Settings from "./settings"; 5 | 6 | if(process.env.NODE_ENV === 'production') { 7 | //@ts-ignore 8 | var {ipcRenderer} = require('electron'); 9 | } 10 | 11 | import '../styles/main_view.scss'; 12 | 13 | let details_cache = new Map(); 17 | 18 | interface PlayerInfo { 19 | id: number; 20 | name: string; 21 | ping: number; 22 | } 23 | 24 | interface ServerDetails { 25 | maxPlayers: number; 26 | players: PlayerInfo[]; 27 | icon?: string; 28 | } 29 | 30 | interface MainViewState { 31 | servers: ServerConfig[]; 32 | current_server: ServerConfig | null; 33 | current_server_details: ServerDetails | null; 34 | 35 | show_settings: boolean; 36 | rippleX: number; 37 | rippleY: number; 38 | 39 | reveal_github_link: boolean; 40 | } 41 | 42 | export default class MainView extends React.Component { 43 | private update_tm: NodeJS.Timeout | null = null; 44 | 45 | state: MainViewState = { 46 | servers: Servers.getList(), 47 | current_server: Servers.getCurrent(), 48 | current_server_details: null, 49 | 50 | show_settings: Servers.getList().length === 0, 51 | rippleX: 0, 52 | rippleY: 0, 53 | 54 | reveal_github_link: false 55 | }; 56 | 57 | constructor(props: any) { 58 | super(props); 59 | } 60 | 61 | componentDidMount() { 62 | if(this.state.current_server) 63 | this.loadServerDetails(this.state.current_server).catch(console.error); 64 | } 65 | 66 | componentWillUnmount() { 67 | if(this.update_tm) 68 | clearTimeout(this.update_tm); 69 | } 70 | 71 | private scheduleUpdate(current_server: ServerConfig) { 72 | if(this.update_tm) 73 | clearTimeout(this.update_tm); 74 | this.update_tm = setTimeout(() => { 75 | this.loadServerDetails(current_server).catch(console.error); 76 | }, 1000*30) as never; 77 | } 78 | 79 | private async loadServerDetails(current_server: ServerConfig) { 80 | try { 81 | const api_url = `http://${current_server.ip}:${current_server.port}`; 82 | console.log('loading details for:', api_url); 83 | 84 | let cache = details_cache.get(api_url); 85 | if(cache) { 86 | this.setState({ 87 | current_server_details: cache.details 88 | }); 89 | 90 | if( Date.now() - cache.update_timestamp < 1000*30 ) { 91 | this.scheduleUpdate(current_server); 92 | return; 93 | } 94 | } 95 | else 96 | details_cache.set(api_url, {details: null, update_timestamp: Date.now()}); 97 | 98 | let info = await getJSON(`${api_url}/info.json`); 99 | 100 | let players: PlayerInfo[] = await getJSON(`${api_url}/players.json`); 101 | 102 | const details: ServerDetails = { 103 | maxPlayers: parseInt(info['vars']['sv_maxClients']), 104 | players: players.sort((a,b) => a.id-b.id), 105 | icon: info['icon'] 106 | }; 107 | 108 | details_cache.set(api_url, {details, update_timestamp: Date.now()}); 109 | 110 | this.setState({ 111 | current_server_details: details 112 | }); 113 | 114 | this.scheduleUpdate(current_server); 115 | } 116 | catch(e) {} 117 | } 118 | 119 | private renderCurrentServerInfo() { 120 | if(!this.state.current_server) 121 | return 'No server selected'; 122 | const details = this.state.current_server_details; 123 | return
124 |
{this.state.current_server.ip}:{this.state.current_server.port}
125 | {details ? <> 126 | {details.icon && 127 | {'server-icon'} 128 | } 129 | {details.players.length} / {details.maxPlayers} 130 | : (Servers.allowConfigure() &&
OFFLINE
)} 131 |
; 132 | } 133 | 134 | private static renderPlayersList(details: ServerDetails) { 135 | return details.players.map((player) => { 136 | return 137 | {player.id} 138 | {trimString(player.name, 25)} 139 | {player.ping} 140 | ; 141 | }); 142 | } 143 | 144 | render() { 145 | return
146 |
147 |
148 | {Servers.allowConfigure() ? :
158 |
{this.renderCurrentServerInfo()}
159 | 169 | {this.state.reveal_github_link ? 170 | Author's github 171 | : 172 |
{ 173 | this.setState({reveal_github_link: true}) 174 | }}>Copyright © 2019 Aktyn
} 175 |
176 |
177 |
{this.state.current_server_details && 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | {MainView.renderPlayersList(this.state.current_server_details)} 187 |
IDNICKPING
188 | }
189 | {this.state.show_settings && { 190 | this.setState({ 191 | servers: list 192 | }); 193 | }} onServerSelected={server => { 194 | this.setState({current_server: server, current_server_details: null}); 195 | if(server) 196 | this.loadServerDetails(server).catch(console.error); 197 | }} onClose={() => this.setState({show_settings: false})} 198 | current_server={this.state.current_server} {...this.state} />} 199 |
; 200 | } 201 | } -------------------------------------------------------------------------------- /src/components/settings.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ServerItem from "./server_item"; 3 | import {ServerConfig} from "../common"; 4 | import Servers from '../servers'; 5 | 6 | import '../styles/settings.scss'; 7 | 8 | function validIP(str: string) { 9 | return str.match(/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/i); 10 | } 11 | 12 | interface SettingsProps { 13 | rippleX: number; 14 | rippleY: number; 15 | 16 | servers: ServerConfig[]; 17 | current_server: ServerConfig | null; 18 | 19 | onServersListUpdate: (list: ServerConfig[]) => void; 20 | onServerSelected: (server: ServerConfig | null) => void; 21 | onClose: () => void; 22 | } 23 | 24 | interface SettingsState { 25 | fade_settings: boolean; 26 | 27 | add_server: boolean; 28 | add_server_error?: string; 29 | 30 | online_servers: number; 31 | } 32 | 33 | export default class Settings extends React.Component { 34 | private closeSettingsTm: NodeJS.Timeout | null = null; 35 | private ip_input: HTMLInputElement | null = null; 36 | private port_input: HTMLInputElement | null = null; 37 | 38 | state: SettingsState = { 39 | fade_settings: false, 40 | 41 | add_server: false, 42 | add_server_error: undefined, 43 | 44 | online_servers: 0 45 | }; 46 | 47 | constructor(props: SettingsProps) { 48 | super(props); 49 | } 50 | 51 | componentWillUnmount(): void { 52 | if(this.closeSettingsTm) 53 | clearTimeout(this.closeSettingsTm); 54 | } 55 | 56 | close() { 57 | this.setState({fade_settings: true}); 58 | this.closeSettingsTm = setTimeout(() => { 59 | this.setState({ 60 | fade_settings: false, 61 | }); 62 | this.props.onClose(); 63 | 64 | this.closeSettingsTm = null; 65 | }, 500) as never;//500ms must not be less then fading animation duration 66 | } 67 | 68 | private addServer() { 69 | if (!this.ip_input || !this.port_input) 70 | return; 71 | if (!validIP(this.ip_input.value)) 72 | return this.setState({add_server_error: 'Incorrect ip address'}); 73 | if (this.port_input.value.length < 1) 74 | return this.setState({add_server_error: 'Incorrect port'}); 75 | 76 | const ip = this.ip_input.value.replace(/^\./, '') 77 | .replace(/\.$/, ''); 78 | const port = parseInt(this.port_input.value.trim()); 79 | 80 | let existing_id = this.props.servers.findIndex(server => { 81 | return !!(server.ip === ip && server.port === port); 82 | }); 83 | 84 | if (existing_id !== -1) 85 | return this.setState({add_server_error: 'Server is already on the list'}); 86 | 87 | const data: ServerConfig = { 88 | ip, 89 | port: parseInt(this.port_input.value) 90 | }; 91 | 92 | this.props.servers.push(data); 93 | Servers.save(this.props.servers); 94 | this.setState({ 95 | add_server_error: undefined, 96 | add_server: false 97 | }); 98 | this.props.onServersListUpdate(this.props.servers); 99 | 100 | if( !this.props.current_server ) { 101 | Servers.setCurrent(data); 102 | this.props.onServerSelected(data); 103 | } 104 | } 105 | 106 | private renderServersList() { 107 | return this.props.servers.map((server) => { 108 | return { 109 | Servers.setCurrent(server); 110 | this.props.onServerSelected(server); 111 | }} onServerOnline={() => { 112 | this.state.online_servers++; 113 | this.setState({ 114 | online_servers: this.state.online_servers 115 | }); 116 | }} onRemove={(online) => { 117 | let server_index = this.props.servers.indexOf(server); 118 | if(server_index === -1) 119 | throw new Error('Cannot remove server that does not exists on the list'); 120 | this.props.servers.splice(server_index, 1); 121 | Servers.save(this.props.servers); 122 | 123 | this.props.onServersListUpdate(this.props.servers); 124 | 125 | if(this.props.current_server === server) { 126 | if(this.props.servers.length > 0) { 127 | let next_i = Math.max(0, server_index-1); 128 | this.props.onServerSelected( this.props.servers[next_i] ); 129 | } 130 | else 131 | this.props.onServerSelected(null); 132 | } 133 | 134 | if(online) { 135 | this.setState({ 136 | online_servers: this.state.online_servers-1 137 | }); 138 | } 139 | }} key={`${server.ip}:${server.port}`} data={server} current={this.props.current_server === server} />; 140 | }); 141 | } 142 | 143 | private renderAddServerForm() { 144 | return
145 |
146 | { 147 | // noinspection RegExpRedundantEscape 148 | e.target.value = e.target.value.replace(/[^\d\.]/gi, '') 149 | .replace(/\.+/g, '.'); 150 | e.target.value = e.target.value.split('.').map((num, i) => { 151 | if (i > 3) 152 | return ''; 153 | let v = parseInt(num.substr(0, 3)); 154 | if (isNaN(v)) 155 | return ''; 156 | return Math.min(v, 255).toString(); 157 | }).join('.'); 158 | }} onBlur={e => { 159 | e.target.value = e.target.value.replace(/^\./, '') 160 | .replace(/\.$/, ''); 161 | if( validIP(e.target.value) ) 162 | e.target.classList.remove('incorrect'); 163 | else 164 | e.target.classList.add('incorrect'); 165 | }} maxLength={15} ref={el => this.ip_input = el} style={{ 166 | textAlign: 'right', 167 | width: '120px' 168 | }} defaultValue={process.env.NODE_ENV === 'development' ? '145.239.133.138' : undefined} /> 169 | : 170 | { 171 | e.target.value = e.target.value.replace(/[^\d]/gi, ''); 172 | }} onBlur={e => { 173 | if(e.target.value.length > 0) 174 | e.target.classList.remove('incorrect'); 175 | else 176 | e.target.classList.add('incorrect'); 177 | }} maxLength={6} ref={el => this.port_input = el} style={{ 178 | textAlign: 'left', 179 | width: '50px' 180 | }} defaultValue={'30120'}/> 181 |
182 | 183 | {this.state.add_server_error &&
{this.state.add_server_error}
} 184 |
; 185 | } 186 | 187 | render() { 188 | return
189 |
193 |
194 | 195 |
196 | 202 |
{this.renderServersList()}
203 | { 204 | this.state.add_server ? this.renderAddServerForm() : 205 | 211 | } 212 |
213 |
214 |
; 215 | } 216 | } --------------------------------------------------------------------------------